diff --git a/actions/setup/js/pi_provider.cjs b/actions/setup/js/pi_provider.cjs index 4501d531f34..6f6c1246949 100644 --- a/actions/setup/js/pi_provider.cjs +++ b/actions/setup/js/pi_provider.cjs @@ -28,8 +28,8 @@ "use strict"; 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 { emitInfrastructureIncomplete } = require("./safeoutputs_cli.cjs"); const fs = require("fs"); -const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); // Default logger: prefixed with "[gh-aw/pi-provider]" for easy grepping. @@ -142,23 +142,16 @@ function formatResponseHeaderNames(headers) { } /** - * Build a structured report_incomplete payload for infrastructure failures. - * - * @param {string} details - * @returns {string} - */ -function buildInfrastructureIncompletePayload(details) { - return JSON.stringify({ - type: "report_incomplete", - reason: "infrastructure_error", - details, - }); -} - -/** - * Append a report_incomplete safe output when provider infrastructure fails + * Emit a report_incomplete safe output when provider infrastructure fails * before any safe outputs have been recorded. * + * This Pi extension runs inside the AWF agent sandbox, where the directory + * backing GH_AW_SAFE_OUTPUTS is mounted read-only (writes fail with EROFS). + * Emission is therefore delegated to the `safeoutputs` CLI (see + * safeoutputs_cli.cjs), which forwards the call to the MCP gateway process + * that owns write access to the real outputs file — the same channel used + * by every other safe-output tool call the agent makes. + * * @param {string} details * @param {(msg: string) => void} logger * @returns {void} @@ -176,14 +169,11 @@ function emitInfrastructureIncompleteIfNoSafeOutputs(details, logger) { logger(`report_incomplete skipped: safe outputs already recorded at ${safeOutputsPath}`); return; } - - fs.mkdirSync(path.dirname(safeOutputsPath), { recursive: true }); - fs.appendFileSync(safeOutputsPath, buildInfrastructureIncompletePayload(details) + "\n", { encoding: "utf8" }); - logger(`report_incomplete emitted: ${safeOutputsPath}`); } catch (error) { - const message = getErrorMessage(error); - logger(`report_incomplete emission failed: ${message}`); + logger(`report_incomplete pre-check failed, proceeding with emission: ${getErrorMessage(error)}`); } + + emitInfrastructureIncomplete(details, { safeOutputsPath, logger }); } /** @@ -411,6 +401,5 @@ _piExports.resolveGatewayUrl = resolveGatewayUrl; _piExports.registerConfiguredProviders = registerConfiguredProviders; _piExports.resolveProviderRequestTarget = resolveProviderRequestTarget; _piExports.formatResponseHeaderNames = formatResponseHeaderNames; -_piExports.buildInfrastructureIncompletePayload = buildInfrastructureIncompletePayload; _piExports.emitInfrastructureIncompleteIfNoSafeOutputs = emitInfrastructureIncompleteIfNoSafeOutputs; _piExports.logReflectFailure = logReflectFailure; diff --git a/actions/setup/js/pi_provider.test.cjs b/actions/setup/js/pi_provider.test.cjs index 5fd98dc9be4..f3f696b7b6b 100644 --- a/actions/setup/js/pi_provider.test.cjs +++ b/actions/setup/js/pi_provider.test.cjs @@ -116,10 +116,14 @@ describe("pi_provider.cjs", () => { expect(stderrOutput.some(line => line.includes("provider_response provider=copilot model=claude-sonnet-4 status=503 method=POST url=http://api-proxy:10002/v1/chat/completions response_headers=content-type,x-request-id"))).toBe(true); }); - it("logs assistant inference errors with the last request target", async () => { + // Triggers the message_end infrastructure-error handler with a given stand-in + // GH_AW_SAFEOUTPUTS_CLI override ('true' simulates a successful CLI call, 'false' + // simulates a failed one) and returns the handlers/stderr output for assertions. + async function triggerConnectionError(cliOverride) { process.env.GH_AW_PI_MODEL = "copilot/claude-sonnet-4"; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-provider-")); process.env.GH_AW_SAFE_OUTPUTS = path.join(tempDir, "outputs.jsonl"); + process.env.GH_AW_SAFEOUTPUTS_CLI = cliOverride; const handlers = {}; const pi = { @@ -150,14 +154,30 @@ describe("pi_provider.cjs", () => { errorMessage: "Connection error.", }, }); + } + + it("logs assistant inference errors with the last request target", async () => { + // 'true' is a stand-in safeoutputs CLI binary that always exits 0, simulating a + // successful emission through the CLI channel instead of a direct fs append. + await triggerConnectionError("true"); expect( stderrOutput.some(line => line.includes('provider_error provider=aw-gateway model=claude-sonnet-4 api=openai-completions status=no-response method=POST url=http://api-proxy:10002/v1/chat/completions response_headers=none error="Connection error."') ) ).toBe(true); - expect(fs.readFileSync(process.env.GH_AW_SAFE_OUTPUTS, "utf8")).toContain('"type":"report_incomplete"'); - expect(fs.readFileSync(process.env.GH_AW_SAFE_OUTPUTS, "utf8")).toContain("Pi provider request failed before safe outputs were emitted"); + // Emission goes through the safeoutputs CLI channel (not a direct fs append), so it + // survives the read-only sandbox mount that backs GH_AW_SAFE_OUTPUTS in production. + expect(stderrOutput.some(line => line.includes("report_incomplete emitted via safeoutputs CLI"))).toBe(true); + }); + + it("logs a failure when the safeoutputs CLI channel is unavailable (e.g. read-only sandbox)", async () => { + // 'false' is a stand-in safeoutputs CLI binary that always exits 1, simulating a + // failed CLI invocation without ever touching the filesystem directly. + await triggerConnectionError("false"); + + expect(stderrOutput.some(line => line.includes("report_incomplete emission failed"))).toBe(true); + expect(fs.existsSync(process.env.GH_AW_SAFE_OUTPUTS)).toBe(false); }); it("skips synthetic report_incomplete emission when safe outputs already exist", () => { @@ -166,9 +186,11 @@ describe("pi_provider.cjs", () => { process.env.GH_AW_SAFE_OUTPUTS = safeOutputsPath; fs.writeFileSync(safeOutputsPath, '{"type":"add_comment","body":"done"}\n'); - module.emitInfrastructureIncompleteIfNoSafeOutputs("temporary outage", () => {}); + const logs = []; + module.emitInfrastructureIncompleteIfNoSafeOutputs("temporary outage", message => logs.push(message)); expect(fs.readFileSync(safeOutputsPath, "utf8")).toBe('{"type":"add_comment","body":"done"}\n'); + expect(logs.some(m => m.includes("skipped: safe outputs already recorded"))).toBe(true); }); it("calls /reflect on the management port (10000) when AWF_REFLECT_ENABLED is set", async () => {