fix(native): bound captured output carried by native command failures (#2764)

* fix(native): bound captured output carried by native command failures

The native command runner retains up to 64 MiB of output because
successful analysis needs it complete, and failureCapture copied all of
it into the failure. An inspect_plist whose plutil output exceeded the
budget therefore returned a 67 MB resource_constraint error whose
details.captured_output.stdout repeated the whole retained output.

Keep a 64 KiB UTF-8-safe excerpt per stream in failure diagnostics,
mark them truncated, and keep the observed stdout/stderr byte totals.
Successful captures are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): bound codesign failures and cover excerpt cuts

translateCodeSignExitFailure wrapped an accepted nonzero codesign capture
into a NativeCommandFailure by copying both complete streams, so
inspect_signature failures still bypassed the diagnostic bound. Route it
through the shared failureCapture builder; accepted output stays
complete until the provider decides it is a failure.

Add a focused runner test for an ordinary nonzero exit with stdout and
stderr just above the excerpt size and a two-byte character straddling
the cut, and a provider test for the codesign path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): build excerpt test output inside the child process

The output strings embedded in one node -e argument totalled about 133
KB, above Linux's 128 KiB per-argument limit, so spawn failed with E2BIG
on the CI runner. Generate the repeated output in the child instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-13 07:03:19 -07:00 committed by GitHub
parent c938f97fb2
commit 5996d8160f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 107 additions and 19 deletions

View File

@ -15,11 +15,18 @@ import type {
ProviderProcessSupervisor,
SpawnedOwnedProviderProcess,
} from "../process/ProviderProcess.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
/** Maximum wall-clock duration for one native command. */
export const NATIVE_COMMAND_TIMEOUT_MS = 60_000;
/** Retain complete command output up to a bounded aggregate size. */
export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024;
/**
* Failure diagnostics keep only an excerpt of each stream. Successful
* analysis needs complete output, but a failed command's error would
* otherwise repeat up to the whole output budget to the caller.
*/
export const NATIVE_FAILURE_DIAGNOSTIC_BYTES = 64 * 1024;
export interface NativeCommandCapture {
readonly tool: string;
@ -383,18 +390,30 @@ type ProcessCapture = Omit<
| "arguments"
>;
const failureCapture = (
/** Bound one command's output for failure diagnostics, keeping observed byte totals. */
export const failureCapture = (
capture: ProcessCapture,
truncated: boolean,
): NativeCommandFailureCapture => ({
stdout: capture.stdout,
stderr: capture.stderr,
): NativeCommandFailureCapture => {
const stdout = boundedUtf8Text(
capture.stdout,
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
);
const stderr = boundedUtf8Text(
capture.stderr,
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
);
return {
stdout: stdout.text,
stderr: stderr.text,
// Byte counts stay the observed totals, not the excerpt lengths.
stdoutBytes: capture.stdoutBytes,
stderrBytes: capture.stderrBytes,
exitCode: capture.exitCode,
signal: capture.signal,
truncated,
});
truncated: truncated || stdout.truncated || stderr.truncated,
};
};
const captureProcess = async (
executable: string,

View File

@ -62,6 +62,7 @@ import {
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
NativeCommandFailure,
XcrunCommandRunner,
failureCapture,
type NativeCommandCleanupFailure,
type NativeCommandCapture,
type NativeCommandRunner,
@ -997,15 +998,8 @@ const translateCodeSignExitFailure = (
"nonzero-exit",
capture.exitCode,
undefined,
{
stdout: capture.stdout,
stderr: capture.stderr,
stdoutBytes: capture.stdoutBytes,
stderrBytes: capture.stderrBytes,
exitCode: capture.exitCode,
signal: capture.signal,
truncated: false,
},
// Accepted output stays complete until the provider decides it failed.
failureCapture(capture, false),
),
);

View File

@ -12,6 +12,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import {
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
NativeCommandFailure,
XcrunCommandRunner,
type NativeCommandRunner,
@ -381,6 +382,36 @@ describe("native command failure capture and limits", () => {
});
});
it("bounds both failure streams at a UTF-8 boundary while keeping their totals", async () => {
const runner = new XcrunCommandRunner({}, () =>
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
);
// "é" is two bytes and straddles the excerpt boundary, so it is dropped whole.
const stdout = `${"x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}é${"y".repeat(1024)}`;
const stderr = "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024);
// Build the output in the child: one Linux argument is limited to 128 KiB.
const result = await runner.run(
"file",
[
"-e",
`const stdout = "x".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}) + "\u00e9" + "y".repeat(1024); const stderr = "z".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024)}); process.stdout.write(stdout, () => process.stderr.write(stderr, () => process.exit(3)));`,
],
{},
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.reason).toBe("nonzero-exit");
expect(result.error.capture).toMatchObject({
stdout: "x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1),
stderr: "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES),
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: Buffer.byteLength(stderr),
exitCode: 3,
truncated: true,
});
});
it("rejects output beyond the complete native-command budget", async () => {
const runner = new XcrunCommandRunner({}, () =>
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
@ -404,6 +435,13 @@ describe("native command failure capture and limits", () => {
(result.error.capture?.stdoutBytes ?? 0) +
(result.error.capture?.stderrBytes ?? 0),
).toBeLessThanOrEqual(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES);
// The failure carries an excerpt, not the whole retained output budget.
expect(result.error.capture?.stdout.length).toBe(
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
);
expect(result.error.capture?.stdoutBytes).toBeGreaterThan(
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
);
});
});
@ -834,6 +872,43 @@ describe("native signature entitlements", () => {
});
});
/** Fail every codesign call nonzero after writing more than one excerpt. */
class VerboseCodeSignFailureRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
const stderr = "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2);
return ok({
...result.value,
stderr,
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
});
}
}
describe("native signature failure diagnostics", () => {
it("bounds accepted nonzero codesign output once it becomes a failure", async () => {
directory = await createTestTempDirectory("rea-signature-failure-");
const executable = join(directory, "fixture");
await writeFile(executable, "fixture");
const client = new NativeMacOSProvider(
{},
new VerboseCodeSignFailureRunner(),
"darwin",
).createClient(await nativeMachoTargetForFile(executable));
const signature = await client.execute("inspect_signature", {});
if (signature.ok) throw new Error("A failed codesign run must fail");
expect(signature.error.capturedOutput).toMatchObject({
stderr: "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES),
stderr_bytes: NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2,
truncated: true,
});
});
});
describe("native dispatch metadata error results", () => {
it("preserves tagged cancellation and integrity failures", async () => {
directory = await createTestTempDirectory("rea-dispatch-errors-");