Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add inline JSON transport for safeoutputs CLI and self-diagnosing no-…
…output reports

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Sep 16, 2026
commit e786783dc04470697cce12bb9fdd8834ba232f7e
94 changes: 92 additions & 2 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3160,6 +3160,90 @@ function buildEngineFailureContext(options = {}) {
}
}

/** Maximum number of safeoutputs CLI command excerpts rendered in the missing-safe-outputs report */
const SAFEOUTPUTS_CLI_EXCERPT_LIMIT = 10;
/** Maximum rendered length of a single safeoutputs CLI command excerpt */
const SAFEOUTPUTS_CLI_EXCERPT_MAX_LENGTH = 300;

/**
* Detect a safeoutputs CLI invocation where the binary never runs because the
* pipe into it was dropped, e.g. `printf '{...}' safeoutputs noop .` instead of
* `printf '{...}' | safeoutputs noop .`. In the dropped-pipe form `printf`/`echo`
* consume `safeoutputs`, the tool name and `.` as trailing arguments, print the
* payload and exit 0, so the failure is indistinguishable from success.
*
* @param {string} line - Single transcript line containing `safeoutputs`
* @returns {boolean}
*/
function isDroppedPipeSafeOutputsCommand(line) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The dropped-pipe heuristic can false-positive on prose. isDroppedPipeSafeOutputsCommand only checks for printf/echo followed by safeoutputs with no |/;/&/>/< in between — it doesn't require safeoutputs to actually be the argument of printf/echo. A transcript line like echo "See docs: safeoutputs add_comment example" gets misclassified as a dropped pipe, and the failure report tells the author "the CLI never ran" when it may never have been invoked at all here (it's just prose in an echo statement).

💡 Reproduction
isDroppedPipeSafeOutputsCommand(`echo "See docs: safeoutputs add_comment example"`) // => true (false positive)

Consider requiring safeoutputs to appear as a quoted-string boundary token (e.g. right after '/") rather than anywhere in the echo/printf argument, or tightening the regex to the actual invocation shape (safeoutputs <tool_name>) followed by no closing quote before it.

@copilot please address this.

const match = /(printf|echo)\b([^\n]*?)safeoutputs\b/.exec(line);
if (!match) return false;
// A pipe (or command separator) between the writer and `safeoutputs` means the
// binary does run; only the separator-free form is silently skipped.
return !/[|;&><]/.test(match[2]);
}

/**
* Extract `safeoutputs` CLI commands from the agent transcript so that a run
* ending with zero safe outputs is self-diagnosing: it distinguishes "the agent
* never tried to emit anything" from "the agent tried and the shell command
* silently skipped the CLI".
*
* @param {string} [stdioLogPathOverride] - Optional explicit transcript path (testing)
* @returns {string} Markdown context block, or an empty string when nothing was found
*/
function buildSafeOutputsCliInvocationContext(stdioLogPathOverride) {
const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT;
const stdioLogPath = stdioLogPathOverride || (agentOutputFile ? path.join(path.dirname(agentOutputFile), "agent-stdio.log") : "/tmp/gh-aw/agent-stdio.log");

let logContent;
try {
logContent = fs.readFileSync(stdioLogPath, "utf8");
} catch {
core.info(`agent-stdio.log not readable at ${stdioLogPath}; skipping safeoutputs CLI invocation context`);
return "";
}

const maskedValues = collectAddMaskedValues(logContent);
/** @type {string[]} */
const commands = [];
const seen = new Set();
let hasDroppedPipe = false;
for (const rawLine of logContent.split("\n")) {
const line = rawLine.trim();
if (!line || !line.includes("safeoutputs")) continue;
if (isAddMaskCommandLine(line)) continue;
// Only keep lines that look like a shell invocation of the CLI.
if (!/(^|[|;&(\s"'`])safeoutputs\s+[a-z_][a-z0-9_]*/i.test(line)) continue;
const redacted = applyAddMaskRedaction(line, maskedValues);
const truncated = redacted.length > SAFEOUTPUTS_CLI_EXCERPT_MAX_LENGTH ? `${redacted.slice(0, SAFEOUTPUTS_CLI_EXCERPT_MAX_LENGTH)}…` : redacted;
if (isDroppedPipeSafeOutputsCommand(line)) {
hasDroppedPipe = true;
}
if (seen.has(truncated)) continue;
seen.add(truncated);
if (commands.length < SAFEOUTPUTS_CLI_EXCERPT_LIMIT) {
commands.push(truncated);
}
}

if (commands.length === 0) {
core.info("No safeoutputs CLI commands found in agent-stdio.log");
return "";
}

core.info(`Found ${commands.length} safeoutputs CLI command(s) in agent-stdio.log${hasDroppedPipe ? " (dropped-pipe pattern detected)" : ""}`);

let context = "**`safeoutputs` commands found in the agent transcript:**\n\n```bash\n";
context += commands.join("\n");
context += "\n```\n\n";
if (hasDroppedPipe) {
context += "A command writes a JSON payload with `printf`/`echo` but has no `|` before `safeoutputs`, so the payload was printed and the CLI never ran (exit code 0, no safe output). ";
context += 'Pass the payload inline instead: `safeoutputs <tool> \'{"key":"value"}\'`.\n\n';
}
return context;
}

/** Cascade detection constants */
const CASCADE_WINDOW_MINUTES = 60;
const CASCADE_WINDOW_MS = CASCADE_WINDOW_MINUTES * 60 * 1000;
Expand Down Expand Up @@ -4077,7 +4161,9 @@ async function main() {
missingSafeOutputsContext += "\n\nThis typically indicates:\n";
missingSafeOutputsContext += "- The safe output server failed to run\n";
missingSafeOutputsContext += "- The prompt failed to generate any meaningful result\n";
missingSafeOutputsContext += "- The agent should have called `noop` to explicitly indicate no action was taken\n\n";
missingSafeOutputsContext += "- The agent should have called `noop` to explicitly indicate no action was taken\n";
missingSafeOutputsContext += "- A `safeoutputs` CLI command was malformed and never invoked the CLI\n\n";
missingSafeOutputsContext += buildSafeOutputsCliInvocationContext();
}

// Build fork context hint
Expand Down Expand Up @@ -4311,7 +4397,9 @@ async function main() {
missingSafeOutputsContext += "\n\nThis typically indicates:\n";
missingSafeOutputsContext += "- The safe output server failed to run\n";
missingSafeOutputsContext += "- The prompt failed to generate any meaningful result\n";
missingSafeOutputsContext += "- The agent should have called `noop` to explicitly indicate no action was taken\n\n";
missingSafeOutputsContext += "- The agent should have called `noop` to explicitly indicate no action was taken\n";
missingSafeOutputsContext += "- A `safeoutputs` CLI command was malformed and never invoked the CLI\n\n";
missingSafeOutputsContext += buildSafeOutputsCliInvocationContext();
}

// Build fork context hint
Expand Down Expand Up @@ -4522,6 +4610,8 @@ module.exports = {
setFailureIssueOutputs,
buildAssignCopilotFailureContext,
buildEngineFailureContext,
buildSafeOutputsCliInvocationContext,
isDroppedPipeSafeOutputsCommand,
detectAWFFirewallStartupFailureFromLog,
buildReportIncompleteContext,
buildMCPPolicyErrorContext,
Expand Down
65 changes: 65 additions & 0 deletions actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2537,6 +2537,71 @@ describe("handle_agent_failure", () => {
// buildEngineFailureContext
// ──────────────────────────────────────────────────────

describe("buildSafeOutputsCliInvocationContext", () => {
let buildSafeOutputsCliInvocationContext;
let isDroppedPipeSafeOutputsCommand;
const fs = require("fs");
const path = require("path");
const os = require("os");

/** @type {string} */
let tmpDir;
/** @type {string} */
let stdioLogPath;

beforeEach(() => {
vi.resetModules();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aw-test-"));
stdioLogPath = path.join(tmpDir, "agent-stdio.log");
process.env.GH_AW_AGENT_OUTPUT = path.join(tmpDir, "agent_output.json");
({ buildSafeOutputsCliInvocationContext, isDroppedPipeSafeOutputsCommand } = require("./handle_agent_failure.cjs"));
});

afterEach(() => {
delete process.env.GH_AW_AGENT_OUTPUT;
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it("returns empty string when the transcript is missing", () => {
expect(buildSafeOutputsCliInvocationContext()).toBe("");
});

it("returns empty string when the transcript has no safeoutputs commands", () => {
fs.writeFileSync(stdioLogPath, "analysis complete\nnothing to report\n");
expect(buildSafeOutputsCliInvocationContext()).toBe("");
});

it("lists safeoutputs commands found in the transcript", () => {
fs.writeFileSync(stdioLogPath, `printf '{"message":"no action needed"}' | safeoutputs noop .\n`);
const result = buildSafeOutputsCliInvocationContext();
expect(result).toContain("safeoutputs` commands found in the agent transcript");
expect(result).toContain("safeoutputs noop .");
expect(result).not.toContain("never ran");
});

it("diagnoses a dropped pipe before safeoutputs", () => {
fs.writeFileSync(stdioLogPath, `printf '{"message":"no action needed"}' safeoutputs noop .\n`);
const result = buildSafeOutputsCliInvocationContext();
expect(result).toContain("the CLI never ran");
expect(result).toContain('safeoutputs <tool> \'{"key":"value"}\'');
});

it("deduplicates repeated commands", () => {
fs.writeFileSync(stdioLogPath, `safeoutputs noop --message "done"\nsafeoutputs noop --message "done"\n`);
const result = buildSafeOutputsCliInvocationContext();
const occurrences = result.split(`safeoutputs noop --message "done"`).length - 1;
expect(occurrences).toBe(1);
});

it("classifies piped and unpiped invocations", () => {
expect(isDroppedPipeSafeOutputsCommand(`printf '{"message":"x"}' safeoutputs noop .`)).toBe(true);
expect(isDroppedPipeSafeOutputsCommand(`printf '{"message":"x"}' | safeoutputs noop .`)).toBe(false);
expect(isDroppedPipeSafeOutputsCommand(`safeoutputs noop --message "x"`)).toBe(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No regression test covers the false-positive case above (prose mentioning safeoutputs inside an echo/printf string being misclassified as a dropped pipe). The existing tests only cover clean-piped and cleanly-dropped-pipe cases, not the ambiguous "safeoutputs appears in the string content, not as the invoked command" case.

💡 Suggested test
it("does not misclassify prose containing 'safeoutputs' as a dropped pipe", () => {
  expect(isDroppedPipeSafeOutputsCommand(`echo "See docs: safeoutputs add_comment example"`)).toBe(false);
});

@copilot please address this.

});
});

describe("buildEngineFailureContext", () => {
let buildEngineFailureContext;
const fs = require("fs");
Expand Down
44 changes: 43 additions & 1 deletion actions/setup/js/mcp_cli_bridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,24 @@ function tryExtractJsonFieldFromStdin(trimmedStdin, canonicalKey, schemaProperti
}
}

/**
* Detect inline JSON payload mode: a single positional argument that is a JSON
* object, e.g. `safeoutputs noop '{"message":"done"}'`.
*
* Returns the trimmed JSON string when the sole argument looks like a JSON
* object (starts with `{`), otherwise null. Detection is intentionally
* permissive about validity so malformed JSON produces a loud parse error
* instead of being silently skipped as a non-flag argument.
*
* @param {string[]} args - User arguments after the tool name
* @returns {string | null}
*/
function findInlineJsonPayloadArg(args) {
if (args.length !== 1) return null;
const trimmed = args[0].trim();
return trimmed.startsWith("{") ? trimmed : null;
}
Comment on lines +755 to +767

/**
* Parse user-provided --key value pairs into a tool arguments object.
* Supports both --key value and --key=value styles.
Expand Down Expand Up @@ -773,6 +791,29 @@ function parseToolArgs(args, schemaProperties = {}, stdinContent = null) {
let jsonOutput = false;
const hasSchemaProperties = Object.keys(schemaProperties).length > 0;
const { normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys } = buildNormalizedSchemaKeyMap(schemaProperties);

// Inline JSON payload mode: the whole payload is passed as a single quoted
// argument, e.g. `safeoutputs noop '{"message":"done"}'`. This keeps the
// binary first on the command line so a malformed invocation still runs the
// CLI and reports an error, unlike the piped `.` sentinel form where a
// dropped pipe silently skips the binary entirely.
const inlineJsonPayload = findInlineJsonPayloadArg(args);
if (inlineJsonPayload !== null) {
let parsed;
try {
parsed = JSON.parse(inlineJsonPayload);
} catch (err) {
throw new Error(`inline JSON argument is not valid JSON: ${getErrorMessage(err)}. Pass a single quoted JSON object, use --key value flags, or pipe JSON on stdin with '.'.`, { cause: err });
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("inline JSON argument must be a JSON object. Pass a single quoted JSON object, use --key value flags, or pipe JSON on stdin with '.'.");
}
for (const [key, value] of Object.entries(parsed)) {
const canonicalKey = resolveSchemaPropertyKey(key, schemaProperties, normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys);
result[canonicalKey] = value;
}
return { args: result, json: false };
}
// Trimmed stdin content used in both JSON payload mode and per-field stdin mode.
const trimmedStdin = stdinContent !== null ? stdinContent.trim() : null;
const isJsonPayloadMode = trimmedStdin !== null && (args.length === 0 || (args.length === 1 && args[0] === "."));
Expand Down Expand Up @@ -1246,7 +1287,7 @@ function showToolHelp(serverName, toolName, tools) {
...renderToolSignature(serverName, tool).split("\n"),
"Recommended:",
...renderToolRecommendedExample(serverName, tool).split("\n"),
`JSON mode: printf '{"param":"value",...}' | ${serverName} ${toolName} .`,
`JSON mode: ${serverName} ${toolName} '{"param":"value",...}' (or: printf '{"param":"value",...}' | ${serverName} ${toolName} .)`,
];

const props = tool.inputSchema?.properties;
Expand Down Expand Up @@ -1697,6 +1738,7 @@ module.exports = {
showToolHelp,
shouldShowToolHelpForEmptyArgs,
hasStdinJsonPayload,
findInlineJsonPayloadArg,
readStdinSync,
ensureSafeOutputsTools,
refreshDeferredToolsIfNeeded,
Expand Down
43 changes: 43 additions & 0 deletions actions/setup/js/mcp_cli_bridge.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ensureSafeOutputsTools,
auditLog,
ensureAuditDir,
findInlineJsonPayloadArg,
formatResponse,
getToolCallTimeoutMs,
hasStdinJsonPayload,
Expand Down Expand Up @@ -908,6 +909,48 @@ describe("mcp_cli_bridge.cjs", () => {
});
});

describe("inline JSON payload argument", () => {
it("detects a single JSON object argument", () => {
expect(findInlineJsonPayloadArg(['{"message":"done"}'])).toBe('{"message":"done"}');
expect(findInlineJsonPayloadArg([' {"message":"done"} '])).toBe('{"message":"done"}');
});

it("does not treat flags, sentinels or plain text as inline JSON", () => {
expect(findInlineJsonPayloadArg(["."])).toBeNull();
expect(findInlineJsonPayloadArg(["--message", '{"a":1}'])).toBeNull();
expect(findInlineJsonPayloadArg(["no action needed"])).toBeNull();
expect(findInlineJsonPayloadArg([])).toBeNull();
});

it("parses an inline JSON object into tool arguments", () => {
const schemaProperties = { issue_number: { type: "integer" }, body: { type: "string" } };

const { args } = parseToolArgs(['{"issue-number": 42, "body": "hello"}'], schemaProperties);

expect(args).toEqual({ issue_number: 42, body: "hello" });
});

it("preserves value types from the inline JSON object", () => {
const { args } = parseToolArgs(['{"count": 5, "enabled": true, "tags": ["a"]}'], {});

expect(args).toEqual({ count: 5, enabled: true, tags: ["a"] });
});

it("prefers the inline JSON argument over stdin content", () => {
const { args } = parseToolArgs(['{"message":"inline"}'], {}, '{"message":"stdin"}');

expect(args).toEqual({ message: "inline" });
});

it("throws a loud error when the inline JSON argument is malformed", () => {
expect(() => parseToolArgs(['{"message":'], {})).toThrow(/inline JSON argument is not valid JSON/i);
});

it("throws when the inline JSON argument is not an object", () => {
expect(() => parseToolArgs(["{} extra"], {})).toThrow(/inline JSON argument is not valid JSON/i);
});
});

describe("stdin JSON payload support", () => {
it("returns true for '.' sentinel", () => {
expect(hasStdinJsonPayload(["."])).toBe(true);
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/mcp_cli_schema_docs.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ function renderToolSignature(serverName, tool, options = {}) {
return `${serverName} ${tool.name}`;
}
if (shouldUseJsonMode(schema)) {
return `printf '%s' '<json object>' | ${serverName} ${tool.name} .`;
return `${serverName} ${tool.name} '<json object>'`;
}
const tokens = [];
for (const entry of optionEntries) {
Expand Down Expand Up @@ -289,7 +289,7 @@ function renderToolRecommendedExample(serverName, tool, options = {}) {
for (const key of orderedKeys) {
payload[key] = exampleValueForKey(key, properties[key]);
}
return `printf '%s' '${JSON.stringify(payload, null, 2)}' | ${serverName} ${tool.name} .`;
return `${serverName} ${tool.name} '${JSON.stringify(payload, null, 2)}'`;
}

const segments = [];
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/mcp_cli_schema_docs.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("mcp_cli_schema_docs.cjs", () => {
};

const signature = renderToolSignature("safeoutputs", tool);
expect(signature).toContain("printf '%s' '<json object>' | safeoutputs add_labels .");
expect(signature).toContain("safeoutputs add_labels '<json object>'");

const docs = renderSafeOutputsPromptDocs("safeoutputs", [tool]);
expect(docs).toContain('"name": "bug"');
Expand Down
5 changes: 3 additions & 2 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -1659,8 +1659,9 @@
"properties": {
"message": {
"type": "string",
"description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing'). This is the only content field noop accepts. Serialize any metrics or structured run summary into this single string; additional named fields (for example processed, skipped) are not accepted and cause a missing-message error.",
"maxLength": 65536
"description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing'). This is the only content field noop accepts; common aliases (reason, summary, details, text, note, status) are accepted and mapped to message. Serialize any metrics or structured run summary into this single string; other named fields (for example processed, skipped) are not accepted and cause a missing-message error.",
"maxLength": 65536,
"x-synonyms": ["reason", "summary", "details", "text", "note", "status"]
},
"secrecy": {
"type": "string",
Expand Down
9 changes: 7 additions & 2 deletions actions/setup/md/mcp_cli_tools_with_safeoutputs_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ For `safeoutputs`, call the tool names listed in `<safe-output-tools>` directly;

For `safeoutputs`, every successful call is a real write-intent declaration - do not use it for probing, auth checks, or placeholder payloads. Use `noop` or `report_incomplete` if not ready to emit the final action.

For multiple or complex arguments, pipe a JSON object on stdin using `.` as the sentinel:
For multiple or complex arguments, pass the JSON object inline as a single quoted argument — the binary comes first, so a malformed command still runs the CLI and reports an error:
```bash
safeoutputs add_comment '{"item_number":42,"body":"### Title\n\nBody."}'
```
Alternatively, pipe a JSON object on stdin using `.` as the sentinel (prefer this only for large bodies built from files):
```bash
printf '{"item_number":42,"body":"### Title\n\nBody."}' | safeoutputs add_comment .
# or write to a file: safeoutputs create_pull_request . < /tmp/payload.json
# or read from a file: safeoutputs create_pull_request . < /tmp/payload.json
```
When piping, make sure the `|` is actually present: `printf '{...}' safeoutputs add_comment .` (no pipe) prints the JSON and exits 0 without ever running the CLI, so nothing is emitted.

**Multi-line or long `body` content:** do NOT build the JSON payload with `printf`/`echo` embedding raw newlines or many escaped characters directly in the command line — the sandbox's shell command-injection guard may reject long or complex quoted arguments (reporting "expansion patterns"/"command substitution" even though none are present) and retrying the identical command will fail again. Instead, write the content to a temp file with a heredoc, then use `jq -Rs` to inject it as the `body` field:
```bash
Expand Down
Loading