diff --git a/.github/workflows/smoke-pi.lock.yml b/.github/workflows/smoke-pi.lock.yml
index 195c3d18071..2eeb733f424 100644
--- a/.github/workflows/smoke-pi.lock.yml
+++ b/.github/workflows/smoke-pi.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"82f001f7c1644077d4b941b7c2da96d79e34980cdf1fa7070b4873f6213c4292","body_hash":"07ea62dc335d2b124062d5af97b2e7ea5f4ec4305c9829a2fd52b147247c8902","strict":true,"agent_id":"pi","agent_model":"copilot/claude-sonnet-4-20250514"}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"332e646a1e6b69707d5eb8bfa7e7c59fe704567fb2b5602b39f575ce8412e8af","body_hash":"07ea62dc335d2b124062d5af97b2e7ea5f4ec4305c9829a2fd52b147247c8902","strict":true,"agent_id":"pi","agent_model":"copilot/claude-sonnet-4-20250514"}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.56"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.56"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.56"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.56"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.20"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4","digest":"sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.4@sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]}
# ___ _ _
# / _ \ | | (_)
@@ -31,6 +31,14 @@
# - shared/reporting.md
# - shared/reporting-otlp.md
#
+# Frontmatter env variables:
+# - AWF_PI_FETCH_DIAGNOSTICS_ENABLED: (main workflow)
+# - AWF_PI_FETCH_DIAGNOSTIC_URLS: (main workflow)
+# - AWF_REFLECT_MAX_ATTEMPTS: (main workflow)
+# - AWF_REFLECT_RETRY_BASE_MS: (main workflow)
+# - AWF_REFLECT_RETRY_MAX_MS: (main workflow)
+# - NODE_DEBUG: (main workflow)
+#
# Secrets used:
# - COPILOT_GITHUB_TOKEN
# - GH_AW_GITHUB_MCP_SERVER_TOKEN
@@ -84,6 +92,12 @@ concurrency:
run-name: "Smoke Pi"
env:
+ AWF_PI_FETCH_DIAGNOSTICS_ENABLED: "1"
+ AWF_PI_FETCH_DIAGNOSTIC_URLS: http://api-proxy:10000/reflect,https://github.com,https://api.github.com/meta
+ AWF_REFLECT_MAX_ATTEMPTS: "7"
+ AWF_REFLECT_RETRY_BASE_MS: "1000"
+ AWF_REFLECT_RETRY_MAX_MS: "10000"
+ NODE_DEBUG: http,https,net,tls,undici
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.GH_AW_OTEL_SENTRY_ENDPOINT }}
OTEL_SERVICE_NAME: gh-aw.smoke-pi
OTEL_RESOURCE_ATTRIBUTES: 'gh-aw.workflow.name=Smoke Pi,gh-aw.repository=${{ github.repository }},gh-aw.run.id=${{ github.run_id }},github.run_id=${{ github.run_id }},gh-aw.engine.id=pi'
@@ -298,21 +312,21 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_e7d5c7caeae63421_EOF'
+ cat << 'GH_AW_PROMPT_5768c12459094f7c_EOF'
- GH_AW_PROMPT_e7d5c7caeae63421_EOF
+ GH_AW_PROMPT_5768c12459094f7c_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_e7d5c7caeae63421_EOF'
+ cat << 'GH_AW_PROMPT_5768c12459094f7c_EOF'
Tools: add_comment(max:2), create_issue, add_labels, missing_tool, missing_data, noop
- GH_AW_PROMPT_e7d5c7caeae63421_EOF
+ GH_AW_PROMPT_5768c12459094f7c_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_e7d5c7caeae63421_EOF'
+ cat << 'GH_AW_PROMPT_5768c12459094f7c_EOF'
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -341,9 +355,9 @@ jobs:
{{/if}}
- GH_AW_PROMPT_e7d5c7caeae63421_EOF
+ GH_AW_PROMPT_5768c12459094f7c_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/cli_proxy_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_e7d5c7caeae63421_EOF'
+ cat << 'GH_AW_PROMPT_5768c12459094f7c_EOF'
{{#runtime-import .github/workflows/shared/gh.md}}
{{#runtime-import .github/workflows/shared/reporting-otlp.md}}
@@ -351,7 +365,7 @@ jobs:
{{#runtime-import .github/workflows/shared/reporting.md}}
{{#runtime-import .github/workflows/shared/noop-reminder.md}}
{{#runtime-import .github/workflows/smoke-pi.md}}
- GH_AW_PROMPT_e7d5c7caeae63421_EOF
+ GH_AW_PROMPT_5768c12459094f7c_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -603,9 +617,9 @@ jobs:
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f317d8f10a782551_EOF'
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_d96168fbb26fde4e_EOF'
{"add_comment":{"hide_older_comments":true,"max":2},"add_labels":{"allowed":["smoke-pi"]},"create_issue":{"close_older_issues":true,"close_older_key":"smoke-pi","expires":2,"labels":["automation","testing"],"max":1},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_f317d8f10a782551_EOF
+ GH_AW_SAFE_OUTPUTS_CONFIG_d96168fbb26fde4e_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
diff --git a/.github/workflows/smoke-pi.md b/.github/workflows/smoke-pi.md
index 041c60c93a9..b3f2afb9bbd 100644
--- a/.github/workflows/smoke-pi.md
+++ b/.github/workflows/smoke-pi.md
@@ -12,6 +12,13 @@ permissions:
contents: read
issues: read
pull-requests: read
+env:
+ NODE_DEBUG: "http,https,net,tls,undici"
+ AWF_REFLECT_MAX_ATTEMPTS: "7"
+ AWF_REFLECT_RETRY_BASE_MS: "1000"
+ AWF_REFLECT_RETRY_MAX_MS: "10000"
+ AWF_PI_FETCH_DIAGNOSTICS_ENABLED: "1"
+ AWF_PI_FETCH_DIAGNOSTIC_URLS: "http://api-proxy:10000/reflect,https://github.com,https://api.github.com/meta"
name: Smoke Pi
experiments:
sub_agent_decomposition:
diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs
index ed0491dc56d..ded9f176eb7 100644
--- a/actions/setup/js/awf_reflect.cjs
+++ b/actions/setup/js/awf_reflect.cjs
@@ -19,7 +19,22 @@ require("./shim.cjs");
const fs = require("fs");
const path = require("path");
-const { withRetry } = require("./error_recovery.cjs");
+const childProcess = require("child_process");
+const { withRetry, isTransientError } = require("./error_recovery.cjs");
+
+/**
+ * Parse a positive integer from an environment variable with fallback.
+ *
+ * @param {string} name
+ * @param {number} fallback
+ * @returns {number}
+ */
+function parsePositiveIntEnv(name, fallback) {
+ const raw = process.env[name];
+ if (!raw) return fallback;
+ const parsed = Number.parseInt(raw, 10);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
// AWF API proxy management endpoint for discovering configured LLM providers and available models.
// The api-proxy sidecar exposes /reflect on its management port (port 10000) inside the AWF
@@ -30,6 +45,12 @@ const AWF_API_PROXY_REFLECT_URL = "http://api-proxy:10000/reflect";
const AWF_REFLECT_OUTPUT_PATH = "/tmp/gh-aw/sandbox/firewall/awf-reflect.json";
// Milliseconds to wait for the /reflect endpoint before giving up.
const AWF_REFLECT_TIMEOUT_MS = 60000;
+// Maximum attempts for fetching /reflect when api-proxy startup is still in progress.
+const AWF_REFLECT_MAX_ATTEMPTS = parsePositiveIntEnv("AWF_REFLECT_MAX_ATTEMPTS", 5);
+// Base delay between /reflect retries. Uses exponential backoff.
+const AWF_REFLECT_RETRY_BASE_MS = parsePositiveIntEnv("AWF_REFLECT_RETRY_BASE_MS", 500);
+// Cap for exponential backoff delay between /reflect retries.
+const AWF_REFLECT_RETRY_MAX_MS = parsePositiveIntEnv("AWF_REFLECT_RETRY_MAX_MS", 5000);
// Milliseconds to wait for each models_url fallback fetch (shorter than the main reflect timeout).
const AWF_MODELS_URL_TIMEOUT_MS = 3000;
// Maximum attempts for models_url fallback fetches when the proxy is not yet ready.
@@ -41,12 +62,71 @@ const AWF_MODELS_URL_RETRY_MAX_MS = 2000;
// Gemini model name prefix stripped from model IDs in the Gemini models API response.
// Example: { name: "models/gemini-1.5-pro" } → "gemini-1.5-pro"
const GEMINI_MODEL_NAME_PREFIX = "models/";
+// HTTP statuses from api-proxy /reflect that are typically transient during startup.
+const RETRYABLE_REFLECT_STATUS_CODES = [502, 503, 504];
+const RETRYABLE_NETWORK_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN"]);
+const PROXY_ENV_VAR_NAMES = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "all_proxy"];
// Default logger used by fetchAWFReflect when no logger is provided via options.
// All lines are prefixed with "[awf-reflect]" for easy grepping in combined logs.
// prettier-ignore
const DEFAULT_REFLECT_LOGGER = /** @type {(msg: string) => void} */ (msg => process.stderr.write(`[awf-reflect] ${new Date().toISOString()} ${msg}\n`));
+/**
+ * Best-effort network probe for /reflect using curl.
+ * This helps distinguish Node.js fetch transport issues from endpoint reachability issues.
+ *
+ * @param {string} reflectUrl
+ * @param {number} timeoutMs
+ * @param {(msg: string) => void} logger
+ * @returns {void}
+ */
+function runReflectCurlProbe(reflectUrl, timeoutMs, logger) {
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
+ const args = ["--silent", "--show-error", "--location", "--output", "/dev/null", "--write-out", "%{http_code}", "--max-time", String(timeoutSeconds), reflectUrl];
+ logger(`awf-reflect: running curl probe for ${reflectUrl}`);
+ try {
+ const result = childProcess.spawnSync("curl", args, {
+ encoding: "utf8",
+ timeout: Math.max(1000, timeoutMs + 1000),
+ maxBuffer: 1024 * 1024,
+ });
+ if (result.error) {
+ logger(`awf-reflect: curl probe failed: ${result.error.message}`);
+ return;
+ }
+
+ const status = (result.stdout || "").trim() || "n/a";
+ const stderr = (result.stderr || "").trim() || "none";
+ const exitCode = typeof result.status === "number" ? result.status : -1;
+ logger(`awf-reflect: curl probe exit=${exitCode} http_status=${status} stderr=${JSON.stringify(stderr)}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger(`awf-reflect: curl probe threw: ${message}`);
+ }
+}
+
+/**
+ * Redact URL credentials for proxy environment variable logging.
+ *
+ * @param {string|undefined} value
+ * @returns {string}
+ */
+function redactProxyEnvValue(value) {
+ if (!value) return "";
+ try {
+ const parsed = new URL(value);
+ if (parsed.username || parsed.password) {
+ parsed.username = "***";
+ parsed.password = "***";
+ return parsed.toString();
+ }
+ } catch {
+ // Keep non-URL values unchanged (for example NO_PROXY host lists).
+ }
+ return value;
+}
+
/**
* Extract model IDs from a provider API response body.
*
@@ -217,6 +297,9 @@ async function enrichReflectModels(reflectData, timeoutMs, logger) {
* reflectUrl?: string,
* outputPath?: string,
* timeoutMs?: number,
+ * maxAttempts?: number,
+ * retryBaseMs?: number,
+ * retryMaxMs?: number,
* modelsTimeoutMs?: number,
* logger?: (msg: string) => void,
* writeFileSync?: (path: string, data: string, options: object) => void,
@@ -235,68 +318,120 @@ async function fetchAWFReflect(options) {
const reflectUrl = (options && options.reflectUrl) || AWF_API_PROXY_REFLECT_URL;
const outputPath = (options && options.outputPath) || AWF_REFLECT_OUTPUT_PATH;
const timeoutMs = options && options.timeoutMs != null ? options.timeoutMs : AWF_REFLECT_TIMEOUT_MS;
+ const configuredAttempts = options && options.maxAttempts != null ? options.maxAttempts : AWF_REFLECT_MAX_ATTEMPTS;
+ const maxAttempts = Math.max(1, configuredAttempts);
+ const retryBaseMs = options && options.retryBaseMs != null ? options.retryBaseMs : AWF_REFLECT_RETRY_BASE_MS;
+ const retryMaxMs = options && options.retryMaxMs != null ? options.retryMaxMs : AWF_REFLECT_RETRY_MAX_MS;
const modelsTimeoutMs = options && options.modelsTimeoutMs != null ? options.modelsTimeoutMs : AWF_MODELS_URL_TIMEOUT_MS;
const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER;
const writeFile = (options && options.writeFileSync) || fs.writeFileSync;
- logger(`awf-reflect: fetching ${reflectUrl} (timeout=${timeoutMs}ms)`);
-
- const ac = new AbortController();
- let timedOut = false;
- const timer = setTimeout(() => {
- timedOut = true;
- logger(`awf-reflect: request timed out after ${timeoutMs}ms`);
- ac.abort();
- }, timeoutMs);
+ const retryConfig = {
+ maxRetries: Math.max(0, maxAttempts - 1),
+ // withRetry doubles the delay after each failure. We halve the initial value so
+ // the first retry sleep is exactly retryBaseMs (instead of 2x retryBaseMs).
+ initialDelayMs: Math.ceil(retryBaseMs / 2),
+ maxDelayMs: retryMaxMs,
+ backoffMultiplier: 2,
+ jitterMs: 0,
+ shouldRetry: error => {
+ const original = error?.originalError || error;
+ const status = original?.status ?? original?.response?.status ?? null;
+ const shouldRetryStatus = RETRYABLE_REFLECT_STATUS_CODES.includes(status);
+ const hasRetryableErrorCode = [original?.code, original?.cause?.code].some(code => typeof code === "string" && RETRYABLE_NETWORK_ERROR_CODES.has(code));
+ const errorMessage = (original?.message || "").toLowerCase();
+ const looksLikeUndiciFetchFailure = original?.name === "TypeError" || errorMessage.includes("fetch failed");
+ const shouldRetryFetchFailure = hasRetryableErrorCode && looksLikeUndiciFetchFailure;
+ const shouldRetry = shouldRetryStatus || isTransientError(original) || shouldRetryFetchFailure;
+ if (shouldRetry) {
+ logger(`awf-reflect: transient failure for ${reflectUrl}; retrying`);
+ }
+ return shouldRetry;
+ },
+ };
try {
- const res = await fetch(reflectUrl, { signal: ac.signal });
- if (!res.ok) {
- logger(`awf-reflect: unexpected status ${res.status}, skipping`);
- return {
- ok: false,
- reflectUrl,
- outputPath,
- reason: "unexpected_status",
- status: res.status,
- };
- }
- const reflectData = await res.json();
- // Attempt to fill in null models for configured providers by fetching directly
- // from each endpoint's models_url. The api-proxy injects auth headers when
- // forwarding these requests, so this succeeds without needing the raw API keys.
- await enrichReflectModels(reflectData, modelsTimeoutMs, logger);
- const enrichedBody = JSON.stringify(reflectData);
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
- writeFile(outputPath, enrichedBody, { encoding: "utf8" });
- logger(`awf-reflect: saved ${enrichedBody.length}B to ${outputPath}`);
- return {
- ok: true,
- reflectUrl,
- outputPath,
- bytesWritten: enrichedBody.length,
- };
+ const proxyEnvSummary = PROXY_ENV_VAR_NAMES.map(name => `${name}=${JSON.stringify(redactProxyEnvValue(process.env[name]))}`).join(" ");
+ logger(`awf-reflect: proxy env ${proxyEnvSummary}`);
+ logger(`awf-reflect: fetching ${reflectUrl} (timeout=${timeoutMs}ms, max_attempts=${maxAttempts})`);
+ return await withRetry(
+ async () => {
+ const ac = new AbortController();
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ logger(`awf-reflect: request timed out after ${timeoutMs}ms`);
+ ac.abort();
+ }, timeoutMs);
+ try {
+ const res = await fetch(reflectUrl, { signal: ac.signal });
+ if (!res.ok) {
+ if (RETRYABLE_REFLECT_STATUS_CODES.includes(res.status)) {
+ const err = Object.assign(new Error(`reflect fetch returned ${res.status} for ${reflectUrl}`), { status: res.status });
+ throw err;
+ }
+ logger(`awf-reflect: unexpected status ${res.status}, skipping`);
+ return {
+ ok: false,
+ reflectUrl,
+ outputPath,
+ reason: "unexpected_status",
+ status: res.status,
+ };
+ }
+ const reflectData = await res.json();
+ // Attempt to fill in null models for configured providers by fetching directly
+ // from each endpoint's models_url. The api-proxy injects auth headers when
+ // forwarding these requests, so this succeeds without needing the raw API keys.
+ await enrichReflectModels(reflectData, modelsTimeoutMs, logger);
+ const enrichedBody = JSON.stringify(reflectData);
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+ writeFile(outputPath, enrichedBody, { encoding: "utf8" });
+ logger(`awf-reflect: saved ${enrichedBody.length}B to ${outputPath}`);
+ return {
+ ok: true,
+ reflectUrl,
+ outputPath,
+ bytesWritten: enrichedBody.length,
+ };
+ } catch (err) {
+ const e = /** @type {Error} */ err;
+ if (e.name === "AbortError") {
+ const timeoutError = Object.assign(new Error(timedOut ? `request timed out after ${timeoutMs}ms` : e.message), { reason: "timeout" });
+ throw timeoutError;
+ }
+ throw e;
+ } finally {
+ clearTimeout(timer);
+ }
+ },
+ retryConfig,
+ `awf-reflect fetch for ${reflectUrl}`
+ );
} catch (err) {
const e = /** @type {Error} */ err;
- if (e.name === "AbortError") {
+ const original = e?.originalError || e;
+ if (original?.reason === "timeout") {
return {
ok: false,
reflectUrl,
outputPath,
reason: "timeout",
- error: timedOut ? `request timed out after ${timeoutMs}ms` : e.message,
+ error: original.message,
};
}
- logger(`awf-reflect: request failed: ${e.message}`);
+ const errorMessage = String(original?.message || e.message || "").toLowerCase();
+ if (original?.name === "TypeError" || errorMessage.includes("fetch failed")) {
+ runReflectCurlProbe(reflectUrl, timeoutMs, logger);
+ }
+ logger(`awf-reflect: request failed: ${original.message || e.message}`);
return {
ok: false,
reflectUrl,
outputPath,
reason: "request_failed",
- error: e.message,
+ error: original.message || e.message,
};
- } finally {
- clearTimeout(timer);
}
}
@@ -305,6 +440,9 @@ if (typeof module !== "undefined" && module.exports) {
AWF_API_PROXY_REFLECT_URL,
AWF_REFLECT_OUTPUT_PATH,
AWF_REFLECT_TIMEOUT_MS,
+ AWF_REFLECT_MAX_ATTEMPTS,
+ AWF_REFLECT_RETRY_BASE_MS,
+ AWF_REFLECT_RETRY_MAX_MS,
AWF_MODELS_URL_TIMEOUT_MS,
AWF_MODELS_URL_MAX_ATTEMPTS,
AWF_MODELS_URL_RETRY_BASE_MS,
diff --git a/actions/setup/js/awf_reflect.test.cjs b/actions/setup/js/awf_reflect.test.cjs
index 30b904a2cfb..e512926381c 100644
--- a/actions/setup/js/awf_reflect.test.cjs
+++ b/actions/setup/js/awf_reflect.test.cjs
@@ -3,12 +3,16 @@ import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";
+import childProcess from "child_process";
const require = createRequire(import.meta.url);
const {
AWF_API_PROXY_REFLECT_URL,
AWF_REFLECT_OUTPUT_PATH,
AWF_REFLECT_TIMEOUT_MS,
+ AWF_REFLECT_MAX_ATTEMPTS,
+ AWF_REFLECT_RETRY_BASE_MS,
+ AWF_REFLECT_RETRY_MAX_MS,
AWF_MODELS_URL_TIMEOUT_MS,
AWF_MODELS_URL_MAX_ATTEMPTS,
AWF_MODELS_URL_RETRY_BASE_MS,
@@ -26,6 +30,9 @@ describe("awf_reflect.cjs", () => {
expect(AWF_API_PROXY_REFLECT_URL).toBe("http://api-proxy:10000/reflect");
expect(AWF_REFLECT_OUTPUT_PATH).toBe("/tmp/gh-aw/sandbox/firewall/awf-reflect.json");
expect(AWF_REFLECT_TIMEOUT_MS).toBe(60000);
+ expect(AWF_REFLECT_MAX_ATTEMPTS).toBe(5);
+ expect(AWF_REFLECT_RETRY_BASE_MS).toBe(500);
+ expect(AWF_REFLECT_RETRY_MAX_MS).toBe(5000);
expect(AWF_MODELS_URL_TIMEOUT_MS).toBe(3000);
expect(AWF_MODELS_URL_MAX_ATTEMPTS).toBe(5);
expect(AWF_MODELS_URL_RETRY_BASE_MS).toBe(250);
@@ -209,6 +216,7 @@ describe("awf_reflect.cjs", () => {
describe("fetchAWFReflect", () => {
afterEach(() => {
vi.unstubAllGlobals();
+ vi.restoreAllMocks();
});
it("saves enriched reflect data when api-proxy returns null models for configured provider", async () => {
@@ -273,8 +281,8 @@ describe("awf_reflect.cjs", () => {
expect(logs.some(l => l.includes("request failed"))).toBe(true);
});
- it("does not throw when the reflect endpoint returns non-ok status", async () => {
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
+ it("does not throw when the reflect endpoint returns non-retryable non-ok status", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 }));
const logs = [];
await expect(
fetchAWFReflect({
@@ -288,9 +296,81 @@ describe("awf_reflect.cjs", () => {
reflectUrl: "http://api-proxy:10000/reflect",
outputPath: "/tmp/gh-aw-test-noop.json",
reason: "unexpected_status",
- status: 503,
+ status: 403,
});
- expect(logs.some(l => l.includes("unexpected status 503"))).toBe(true);
+ expect(logs.some(l => l.includes("unexpected status 403"))).toBe(true);
+ });
+
+ it("retries transient reflect status failures and eventually succeeds", async () => {
+ const reflectPayload = {
+ endpoints: [{ provider: "openai", port: 10000, configured: true, models: ["gpt-4o"], models_url: "http://api-proxy:10000/v1/models" }],
+ models_fetch_complete: true,
+ };
+
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 503 })
+ .mockResolvedValueOnce({ ok: false, status: 503 })
+ .mockResolvedValue({ ok: true, status: 200, json: async () => reflectPayload })
+ );
+
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "awf-reflect-retry-test-"));
+ const outputPath = path.join(outputDir, "awf-reflect.json");
+
+ try {
+ const result = await fetchAWFReflect({
+ reflectUrl: "http://api-proxy:10000/reflect",
+ outputPath,
+ timeoutMs: 1000,
+ maxAttempts: 3,
+ retryBaseMs: 1,
+ retryMaxMs: 2,
+ logger: () => {},
+ });
+
+ expect(result.ok).toBe(true);
+ expect(fs.existsSync(outputPath)).toBe(true);
+ } finally {
+ fs.rmSync(outputDir, { recursive: true, force: true });
+ }
+ });
+
+ it("retries fetch failed when the nested cause is transient and eventually succeeds", async () => {
+ const reflectPayload = {
+ endpoints: [{ provider: "openai", port: 10000, configured: true, models: ["gpt-4o"], models_url: "http://api-proxy:10000/v1/models" }],
+ models_fetch_complete: true,
+ };
+ const fetchFailedWithTransientCause = Object.assign(new Error("fetch failed"), { cause: { code: "ECONNREFUSED" } });
+
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockRejectedValueOnce(fetchFailedWithTransientCause)
+ .mockResolvedValue({ ok: true, status: 200, json: async () => reflectPayload })
+ );
+
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "awf-reflect-retry-fetch-failed-test-"));
+ const outputPath = path.join(outputDir, "awf-reflect.json");
+
+ try {
+ const result = await fetchAWFReflect({
+ reflectUrl: "http://api-proxy:10000/reflect",
+ outputPath,
+ timeoutMs: 1000,
+ maxAttempts: 3,
+ retryBaseMs: 1,
+ retryMaxMs: 2,
+ logger: () => {},
+ });
+
+ expect(result.ok).toBe(true);
+ expect(fs.existsSync(outputPath)).toBe(true);
+ } finally {
+ fs.rmSync(outputDir, { recursive: true, force: true });
+ }
});
it("uses the caller-supplied logger for all messages", async () => {
@@ -304,5 +384,77 @@ describe("awf_reflect.cjs", () => {
});
expect(collected.length).toBeGreaterThan(0);
});
+
+ it("runs a curl probe when fetch fails with a Node.js fetch error", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed")));
+ const spawnSyncSpy = vi.spyOn(childProcess, "spawnSync").mockReturnValue({
+ status: 0,
+ stdout: "200",
+ stderr: "",
+ });
+ const logs = [];
+
+ await expect(
+ fetchAWFReflect({
+ reflectUrl: "http://api-proxy:10000/reflect",
+ outputPath: "/tmp/gh-aw-test-noop.json",
+ timeoutMs: 500,
+ maxAttempts: 1,
+ logger: msg => logs.push(msg),
+ })
+ ).resolves.toEqual({
+ ok: false,
+ reflectUrl: "http://api-proxy:10000/reflect",
+ outputPath: "/tmp/gh-aw-test-noop.json",
+ reason: "request_failed",
+ error: "fetch failed",
+ });
+
+ expect(spawnSyncSpy).toHaveBeenCalledOnce();
+ expect(spawnSyncSpy.mock.calls[0][0]).toBe("curl");
+ expect(spawnSyncSpy.mock.calls[0][1]).toContain("http://api-proxy:10000/reflect");
+ expect(logs.some(l => l.includes("running curl probe"))).toBe(true);
+ expect(logs.some(l => l.includes("curl probe exit=0 http_status=200"))).toBe(true);
+ });
+
+ it("logs proxy environment variables before reflect fetch", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
+ const originalProxyEnvs = Object.fromEntries(["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "all_proxy"].map(name => [name, process.env[name]]));
+ process.env.HTTP_PROXY = "http://proxy.internal:3128";
+ process.env.HTTPS_PROXY = "http://proxy-secure.internal:3129";
+ process.env.NO_PROXY = "localhost,127.0.0.1,api-proxy";
+ process.env.ALL_PROXY = "socks5://proxy-all.internal:1080";
+ process.env.http_proxy = "http://proxy-lower.internal:3130";
+ process.env.https_proxy = "http://proxy-lower-secure.internal:3131";
+ process.env.no_proxy = "localhost";
+ process.env.all_proxy = "socks5://proxy-lower-all.internal:1081";
+ const logs = [];
+
+ try {
+ await fetchAWFReflect({
+ reflectUrl: "http://api-proxy:10000/reflect",
+ outputPath: "/tmp/gh-aw-test-noop.json",
+ timeoutMs: 500,
+ maxAttempts: 1,
+ logger: msg => logs.push(msg),
+ });
+ } finally {
+ for (const [name, value] of Object.entries(originalProxyEnvs)) {
+ if (value === undefined) delete process.env[name];
+ else process.env[name] = value;
+ }
+ }
+
+ const proxyLogLine = logs.find(l => l.includes("awf-reflect: proxy env "));
+ expect(proxyLogLine).toBeTruthy();
+ expect(proxyLogLine).toContain('HTTP_PROXY="http://proxy.internal:3128"');
+ expect(proxyLogLine).toContain('HTTPS_PROXY="http://proxy-secure.internal:3129"');
+ expect(proxyLogLine).toContain('NO_PROXY="localhost,127.0.0.1,api-proxy"');
+ expect(proxyLogLine).toContain('ALL_PROXY="socks5://proxy-all.internal:1080"');
+ expect(proxyLogLine).toContain('http_proxy="http://proxy-lower.internal:3130"');
+ expect(proxyLogLine).toContain('https_proxy="http://proxy-lower-secure.internal:3131"');
+ expect(proxyLogLine).toContain('no_proxy="localhost"');
+ expect(proxyLogLine).toContain('all_proxy="socks5://proxy-lower-all.internal:1081"');
+ });
});
});
diff --git a/actions/setup/js/pi_provider.cjs b/actions/setup/js/pi_provider.cjs
index f9a83be59f5..c276f4d8f2a 100644
--- a/actions/setup/js/pi_provider.cjs
+++ b/actions/setup/js/pi_provider.cjs
@@ -30,6 +30,9 @@
const { fetchAWFReflect, AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, AWF_REFLECT_TIMEOUT_MS, AWF_MODELS_URL_TIMEOUT_MS } = require("./awf_reflect.cjs");
const fs = require("fs");
const path = require("path");
+const DEFAULT_FETCH_DIAGNOSTIC_URLS = ["http://api-proxy:10000/reflect", "https://github.com", "https://api.github.com/meta"];
+const DEFAULT_FETCH_DIAGNOSTIC_TIMEOUT_MS = 5000;
+const PROXY_ENV_VAR_NAMES = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
// Default logger: prefixed with "[gh-aw/pi-provider]" for easy grepping.
// prettier-ignore
@@ -95,6 +98,40 @@ function joinApiUrl(baseUrl, apiPath) {
return `${baseUrl.replace(/\/+$/, "")}${apiPath}`;
}
+/**
+ * Ensure an internal host bypasses proxy routing for Node fetch/undici.
+ *
+ * @param {string} host
+ * @param {(msg: string) => void} logger
+ */
+function ensureNoProxyHost(host, logger) {
+ if (!host) return;
+ const hasProxyEnv = PROXY_ENV_VAR_NAMES.some(name => Boolean(process.env[name]));
+ if (!hasProxyEnv) return;
+
+ const hostLower = host.toLowerCase();
+ const entries = [process.env.NO_PROXY, process.env.no_proxy]
+ .filter(v => typeof v === "string" && v.length > 0)
+ .flatMap(v => v.split(","))
+ .map(v => v.trim())
+ .filter(Boolean);
+ const normalizedEntries = [];
+ const seen = new Set();
+ for (const entry of entries) {
+ const lower = entry.toLowerCase();
+ if (seen.has(lower)) continue;
+ seen.add(lower);
+ normalizedEntries.push(entry);
+ }
+ const alreadyPresent = seen.has(hostLower);
+ if (alreadyPresent) return;
+
+ const updated = [...normalizedEntries, host].join(",");
+ process.env.NO_PROXY = updated;
+ process.env.no_proxy = updated;
+ logger(`proxy_bypass host=${host} no_proxy=${updated}`);
+}
+
/**
* Resolve the Pi model's inferred provider request target for logging.
*
@@ -215,6 +252,119 @@ function logReflectFailure(params) {
logger(`reflect_failure phase=${phase} provider=${provider || "(no provider prefix)"} model=${model || "(not set)"} url=${result.reflectUrl} output=${result.outputPath} reason=${result.reason || "unknown"}${status}${error}`);
}
+/**
+ * Parse comma-separated URL list for Pi fetch diagnostics.
+ *
+ * @returns {string[]}
+ */
+function getFetchDiagnosticUrls() {
+ const raw = process.env.AWF_PI_FETCH_DIAGNOSTIC_URLS;
+ if (!raw) {
+ return DEFAULT_FETCH_DIAGNOSTIC_URLS;
+ }
+ const parsed = raw
+ .split(",")
+ .map(v => v.trim())
+ .filter(Boolean);
+ return parsed.length > 0 ? parsed : DEFAULT_FETCH_DIAGNOSTIC_URLS;
+}
+
+/**
+ * Return true when Pi fetch diagnostics should run.
+ *
+ * @returns {boolean}
+ */
+function isFetchDiagnosticsEnabled() {
+ return process.env.AWF_PI_FETCH_DIAGNOSTICS_ENABLED === "1";
+}
+
+/**
+ * Capture a compact body preview for fetch diagnostics logs.
+ *
+ * @param {Response} response
+ * @returns {Promise}
+ */
+async function readBodyPreview(response) {
+ const maxBytes = 4096;
+
+ // Prefer streaming reads to avoid loading large response bodies into memory.
+ if (response?.body && typeof response.body.getReader === "function") {
+ const reader = response.body.getReader();
+ /** @type {Buffer[]} */
+ const chunks = [];
+ let totalBytes = 0;
+
+ try {
+ while (totalBytes < maxBytes) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ const chunk = Buffer.from(value);
+ const remaining = maxBytes - totalBytes;
+ if (chunk.length > remaining) {
+ chunks.push(chunk.subarray(0, remaining));
+ totalBytes += remaining;
+ break;
+ }
+ chunks.push(chunk);
+ totalBytes += chunk.length;
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return `(body read failed: ${message})`;
+ } finally {
+ try {
+ await reader.cancel();
+ } catch {}
+ }
+
+ const normalized = Buffer.concat(chunks).toString("utf8").replace(/\s+/g, " ").trim();
+ if (!normalized) return "(empty body)";
+ return normalized.slice(0, 200);
+ }
+
+ try {
+ const text = await response.text();
+ const normalized = text.replace(/\s+/g, " ").trim();
+ return normalized.slice(0, 200) || "(empty body)";
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return `(body read failed: ${message})`;
+ }
+}
+
+/**
+ * Log runtime fetch diagnostics for Node 24 transport debugging.
+ *
+ * @param {{
+ * phase: string,
+ * logger: (msg: string) => void,
+ * timeoutMs?: number,
+ * urls?: string[],
+ * }} params
+ * @returns {Promise}
+ */
+async function runFetchDiagnostics(params) {
+ const { phase, logger } = params;
+ const timeoutMs = typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : DEFAULT_FETCH_DIAGNOSTIC_TIMEOUT_MS;
+ const urls = Array.isArray(params.urls) && params.urls.length > 0 ? params.urls : getFetchDiagnosticUrls();
+ logger(`pi-fetch-diagnostics phase=${phase} node=${process.version} platform=${process.platform} undici=${process.versions?.undici || "unknown"} fetch_type=${typeof fetch}`);
+ for (const probeUrl of urls) {
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+ try {
+ const response = await fetch(probeUrl, { signal: ac.signal });
+ const outputPreview = await readBodyPreview(response);
+ logger(`pi-fetch-diagnostics url=${probeUrl} final_url=${response.url || probeUrl} status=${response.status} ok=${response.ok} output=${JSON.stringify(outputPreview)}`);
+ } catch (error) {
+ const err = /** @type {any} */ error;
+ const message = err instanceof Error ? err.message : String(err);
+ logger(`pi-fetch-diagnostics url=${probeUrl} failed error=${JSON.stringify(message)} code=${err?.code || err?.cause?.code || "n/a"}`);
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+}
+
/**
* Register a Pi provider and any aliases.
*
@@ -308,6 +458,7 @@ function registerConfiguredProviders(pi, logger) {
*/
function piProviderExtension(pi) {
const log = DEFAULT_LOGGER;
+ ensureNoProxyHost(new URL(AWF_API_PROXY_REFLECT_URL).hostname, log);
/** @type {{ api: string, method: string, url: string }|null} */
let lastProviderRequest = null;
/** @type {{ status: number, responseHeaders: string }|null} */
@@ -362,6 +513,10 @@ function piProviderExtension(pi) {
log(`model=${model || "(not set)"} (no provider prefix — defaulting to Copilot gateway)`);
}
+ if (isFetchDiagnosticsEnabled()) {
+ await runFetchDiagnostics({ phase: "agent_start", logger: log });
+ }
+
// Fetch AWF API proxy reflection data before the agent runs to capture initial proxy state.
// This is best-effort: failures are logged but do not affect the agent session.
// Skip when AWF_REFLECT_ENABLED is not "1" (e.g. sandbox.agent: false — no api-proxy running).
diff --git a/actions/setup/js/pi_provider.test.cjs b/actions/setup/js/pi_provider.test.cjs
index a4530f2019c..6c3a555e48b 100644
--- a/actions/setup/js/pi_provider.test.cjs
+++ b/actions/setup/js/pi_provider.test.cjs
@@ -13,6 +13,9 @@ describe("pi_provider.cjs", () => {
originalEnv = { ...process.env };
originalFetch = global.fetch;
stderrOutput = [];
+ process.env.AWF_REFLECT_MAX_ATTEMPTS = "1";
+ process.env.AWF_REFLECT_RETRY_BASE_MS = "1";
+ process.env.AWF_REFLECT_RETRY_MAX_MS = "1";
vi.spyOn(process.stderr, "write").mockImplementation(msg => {
stderrOutput.push(String(msg));
return true;
@@ -61,6 +64,23 @@ describe("pi_provider.cjs", () => {
]);
});
+ it("adds api-proxy to no_proxy when proxy vars are set", () => {
+ process.env.HTTP_PROXY = "http://proxy.internal:3128";
+ process.env.NO_PROXY = "localhost,127.0.0.1";
+ process.env.no_proxy = "localhost";
+
+ const pi = {
+ registerProvider: vi.fn(),
+ on: vi.fn(),
+ };
+
+ module.default(pi);
+
+ expect(process.env.NO_PROXY).toBe("localhost,127.0.0.1,api-proxy");
+ expect(process.env.no_proxy).toBe("localhost,127.0.0.1,api-proxy");
+ expect(stderrOutput.some(line => line.includes("proxy_bypass host=api-proxy no_proxy=localhost,127.0.0.1,api-proxy"))).toBe(true);
+ });
+
it("logs the configured provider using GH_AW_PI_MODEL during agent_start", async () => {
process.env.GH_AW_PI_MODEL = "copilot/claude-sonnet-4";
global.fetch = vi.fn().mockRejectedValue(new Error("network disabled"));
@@ -219,6 +239,42 @@ describe("pi_provider.cjs", () => {
).toBe(true);
});
+ it("runs fetch diagnostics with node runtime details when enabled", async () => {
+ process.env.AWF_PI_FETCH_DIAGNOSTICS_ENABLED = "1";
+ process.env.AWF_PI_FETCH_DIAGNOSTIC_URLS = "https://github.com,https://api.github.com/meta";
+ global.fetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ url: "https://github.com/",
+ text: vi.fn().mockResolvedValue("GitHub"),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ url: "https://api.github.com/meta",
+ text: vi.fn().mockResolvedValue('{"verifiable_password_authentication":false}'),
+ });
+
+ const handlers = {};
+ const pi = {
+ registerProvider: vi.fn(),
+ on: vi.fn((event, handler) => {
+ handlers[event] = handler;
+ }),
+ };
+
+ module.default(pi);
+ await handlers.agent_start();
+
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(stderrOutput.some(line => line.includes("pi-fetch-diagnostics phase=agent_start node="))).toBe(true);
+ expect(stderrOutput.some(line => line.includes("pi-fetch-diagnostics url=https://github.com"))).toBe(true);
+ expect(stderrOutput.some(line => line.includes("status=200 ok=true"))).toBe(true);
+ expect(stderrOutput.some(line => line.includes("pi-fetch-diagnostics url=https://api.github.com/meta"))).toBe(true);
+ });
+
it("skips /reflect when AWF_REFLECT_ENABLED is not set", async () => {
process.env.GH_AW_PI_MODEL = "copilot/claude-sonnet-4";
delete process.env.AWF_REFLECT_ENABLED;