diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a10f9855cbbbf..f47b83c8c6573 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -905,6 +905,17 @@ const program = Effect.gen(function* () { ); return {}; } + if ( + process.env.T3_ACP_CRASH_PROMPT === "1" && + request.prompt.some( + (part) => + part.type === "text" && + typeof part.text === "string" && + part.text.includes("crash now"), + ) + ) { + return yield* Effect.sync(() => process.exit(23)); + } if (clientFsProbePath !== undefined && clientFsProbeLogPath !== undefined) { const probes = [ diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 0f5670f1d2de0..566876d7d180b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -41,6 +41,8 @@ import { makeAntigravityAdapterV2, } from "./AntigravityAdapterV2.ts"; +import { makeTurnInput } from "./AcpAdapterV2.testkit.ts"; + const flavor = makeAntigravityAcpAdapterFlavor({ instanceId: ProviderInstanceId.make("antigravity-test"), crypto: undefined as never, @@ -128,6 +130,88 @@ const layerSession = Layer.mergeAll( TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); +describe("AntigravityAdapterV2 process failures", () => { + it.effect("keeps a crashed child's exit code and redacted stderr in the run failure", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "acp-exit-test-" }); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("antigravity-exit-test"); + const threadId = ThreadId.make("thread-antigravity-exit-test"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [ + "-e", + `process.stderr.write("Synthetic configuration failed.\\nAuthorization: Bearer fixture-only-token\\n"); import(${JSON.stringify(mockAgentPath)});`, + ], + cwd: workspace, + extendEnv: false, + env: { + HOME: workspace, + USERPROFILE: workspace, + T3_ACP_ANTIGRAVITY: "1", + T3_ACP_CRASH_PROMPT: "1", + }, + }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - The optional model result is undefined. + defaultModel: Effect.succeed(undefined), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const modelSelection = { instanceId, model: "gemini-test-low" } as const; + const session = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-exit-test"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* session.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection, + now: yield* DateTime.now, + messageText: "crash now", + }), + ); + const events = yield* session.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminal = Array.from(events).find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "failed"); + assert.include(terminal?.failure?.message ?? "", "ACP process exited with code 23"); + assert.include(terminal?.failure?.message ?? "", "Synthetic configuration failed."); + assert.notInclude(terminal?.failure?.message ?? "", "fixture-only-token"); + assert.equal(terminal?.failure?.code, "23"); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); + describe("AntigravityAdapterV2 session open", () => { it.effect.each([ { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 9e2da61be2af3..3d83e22ad7a35 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -361,6 +361,40 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { + name: "a final diagnostic", + prefix: "api_key=", + suffix: "\nFinal synthetic crash diagnostic.", + expected: undefined, + }, + { name: "no complete line", prefix: "api_key=", suffix: "", expected: undefined }, + { + name: "a clipped private-key header", + prefix: "-----BEGIN PRIVATE KEY-----\n", + suffix: "\n-----END PRIVATE KEY-----", + expected: undefined, + }, + ])("omits a credential cut by the stderr tail boundary with $name", (testCase) => + Effect.gen(function* () { + const stderr = `${testCase.prefix}${"fixturecredential".repeat(600)}${testCase.suffix}`; + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + command: process.execPath, + args: ["-e", `process.stderr.write(${JSON.stringify(stderr)}, () => process.exit(23));`], + extendEnv: false, + env: {}, + }, + }); + const error = yield* runtime.start().pipe(Effect.flip); + assert.instanceOf(error, EffectAcpErrors.AcpProcessExitedError); + assert.equal(error.code, 23); + assert.isFalse(error.stderr?.includes("fixturecredential") ?? false); + assert.equal(error.stderr, testCase.expected); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("drains large stderr output and keeps auth-sized logging chunks", () => Effect.gen(function* () { const lengths: Array = []; diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index 13e05dc926f67..3f26155d3936d 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -135,7 +135,10 @@ optional-service layer. Don't route around the layer with an imperative runtime. lives only in `cause`. Expose a category, length, count, or a URL's protocol and host instead. `DeviceBootError.diagnostics` is a deliberate exception: authorized device users and agents need the device hub's launch command and failure text to diagnose host setup. It is capped at - 2,000 characters and included in the error message; other service errors keep the rule above. + 2,000 characters and included in the error message. ACP process-exit failures may include a + sanitized stderr excerpt within the 4,096-character failure-message cap: credential-labelled + lines and private-key bodies are withheld, and capped raw tails are omitted because a + credential header may have been clipped. Other service errors keep the rule above. - **Translation.** Construct the error where the failure happens, and map it to a transport error only in the transport. A translation boundary passes through domain errors already in the target channel and wraps only unknown or lower-level failures. Map each failure where its context is diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 300a9359e40dd..311eae63a297c 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -32,7 +32,11 @@ import { AgentScope, AgentScopeThreadId, RAISE_OOM_SCORE_LINE } from "@t3tools/s import * as HostProcess from "@t3tools/shared/HostProcess"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; -import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts"; +import { + ACP_STDERR_TAIL_MAX_CHARS, + appendAcpStderrTail, + sanitizeAcpStderrExcerpt, +} from "./stderr.ts"; import { collectSessionConfigOptionValues, decideToolCallUpdateEmission, @@ -1294,7 +1298,11 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( Effect.ignore, Effect.andThen(Ref.get(stderrTailRef)), Effect.map((tail) => { - const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory); + // A capped tail can start inside a multiline credential whose header was discarded. + const stderr = + tail.length < ACP_STDERR_TAIL_MAX_CHARS + ? sanitizeAcpStderrExcerpt(tail, homeDirectory) + : ""; return stderr.length === 0 ? error : new EffectAcpErrors.AcpProcessExitedError({ diff --git a/packages/provider-acp/src/server/nativeLogging.test.ts b/packages/provider-acp/src/server/nativeLogging.test.ts index a394a18154af6..d38e63485ea14 100644 --- a/packages/provider-acp/src/server/nativeLogging.test.ts +++ b/packages/provider-acp/src/server/nativeLogging.test.ts @@ -47,6 +47,42 @@ nodeServicesIt("ACP native logging", (it) => { }), ); + it.effect("records the sanitized exit diagnostic for a crashed ACP request", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "synthetic-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("antigravity"), + threadId: ThreadId.make("synthetic-crash-thread"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: { prompt: "synthetic prompt" }, + status: "failed", + cause: Cause.fail( + new AcpErrors.AcpProcessExitedError({ + code: 23, + stderr: "Synthetic configuration failed.\napi_key=fixture-only-key", + }), + ), + }); + const serialized = encodeUnknownJson(records); + assert.include(serialized, "ACP process exited with code 23"); + assert.include(serialized, "Synthetic configuration failed."); + assert.include(serialized, '"code":"23"'); + assert.notInclude(serialized, "fixture-only-key"); + assert.notInclude(serialized, "synthetic prompt"); + }), + ); + it.effect("preserves native writer interruption", () => Effect.gen(function* () { const makeLogger = yield* makeAcpNativeLoggerFactory(); diff --git a/packages/provider-acp/src/server/nativeLogging.ts b/packages/provider-acp/src/server/nativeLogging.ts index eb11d9693c240..a6b5982624796 100644 --- a/packages/provider-acp/src/server/nativeLogging.ts +++ b/packages/provider-acp/src/server/nativeLogging.ts @@ -5,6 +5,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; + import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { structuralProtocolMethod, @@ -22,6 +24,9 @@ function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEv ? { errorTag: causeErrorTag(event.cause), reasonCount: event.cause.reasons.length, + ...(causeErrorTag(event.cause) === "AcpProcessExitedError" + ? { failure: makeProviderFailure({ cause: event.cause, class: "provider_error" }) } + : {}), } : {}), }; diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts index fdf3c68f97e4d..bcda5438c7888 100644 --- a/packages/provider-acp/src/server/stderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -27,6 +27,18 @@ describe("AcpStderr", () => { expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); }); + it("withholds multiline private-key bodies, including unterminated keys", () => { + for (const end of ["-----END PRIVATE KEY-----", ""]) { + const excerpt = sanitizeAcpStderrExcerpt( + `Configuration failed.\n-----BEGIN PRIVATE KEY-----\nZml4dHVyZS1wcml2YXRlLWJvZHk=\n${end}`, + "/home/server", + {}, + ); + expect(excerpt).toContain("Configuration failed."); + expect(excerpt).not.toContain("Zml4dHVyZS1wcml2YXRlLWJvZHk="); + } + }); + it("redacts hyphenated OpenAI project keys and header credentials", () => { const excerpt = sanitizeAcpStderrExcerpt( [ @@ -35,6 +47,7 @@ describe("AcpStderr", () => { "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", "Authorization: Basic dXNlcjpwYXNz", "x-api-key: ant-api-key-value", + "github_pat_fixture_123 gho_fixture123 ghu_fixture123 ghs_fixture123 ghr_fixture123", ].join("\n"), "/Users/ada", {}, @@ -49,6 +62,8 @@ describe("AcpStderr", () => { expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); expect(excerpt).not.toContain("dXNlcjpwYXNz"); expect(excerpt).not.toContain("ant-api-key-value"); + expect(excerpt).not.toContain("fixture123"); + expect(excerpt).not.toContain("fixture_123"); }); it("redacts the whole key when its body contains underscores", () => { diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index 92ae01a4c27ef..aaba4d3779052 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -1,4 +1,4 @@ -const ACP_STDERR_TAIL_MAX_CHARS = 4_096; +export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; const SIGN_IN_URL_PATTERN = /https?:\/\/accounts\.google\.com[^\s]*/gi; @@ -6,7 +6,7 @@ const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; const SECRET_TOKEN_PATTERN = - /\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; export function appendAcpStderrTail(current: string, chunk: string): string { const next = `${current}${chunk}`; @@ -18,7 +18,9 @@ export function sanitizeAcpStderrExcerpt( homeDirectory: string, environment: NodeJS.ProcessEnv = process.env, ): string { - let result = text.replaceAll("\0", ""); + let result = text + .replaceAll("\0", "") + .replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*/giu, "[redacted private key]"); const homes = [environment.HOME, environment.USERPROFILE, homeDirectory].filter( (value): value is string => typeof value === "string" && value.length > 1, ); diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index a50d3dac9599c..a462a09b9f44a 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -41,6 +41,155 @@ it("redacts credentials and URL secrets from provider failures", () => { assert.notInclude(failure.message, "sk-abcdefghijklmnop"); }); +it("keeps structured ACP exit details through cause wrappers without reading arbitrary messages", () => { + const cause = { + _tag: "AcpProcessExitedError", + code: 23, + stderr: "Synthetic configuration failed.\napi_key=fixture-only-key", + message: "Untrusted alternative message", + }; + for (const value of [ + cause, + Cause.fail(cause), + { _tag: "ProviderAdapterTurnStartError", cause }, + ]) { + const failure = makeProviderFailure({ cause: value, class: "provider_error" }); + assert.equal(failure.code, "23"); + assert.equal( + failure.message, + "ACP process exited with code 23\nSynthetic configuration failed.\n[REDACTED credential diagnostic]", + ); + } + assert.equal( + makeProviderFailure({ cause, message: "Explicit failure", code: "explicit" }).message, + "Explicit failure", + ); + assert.equal(makeProviderFailure({ cause, code: "explicit" }).code, "explicit"); + assert.equal( + makeProviderFailure({ cause: { _tag: "AcpProcessExitedError", signal: "SIGTERM" } }).message, + "ACP process exited after signal SIGTERM", + ); + assert.equal( + makeProviderFailure({ cause: { _tag: "AcpProcessExitedError", signal: "SIGTERM" } }).code, + "SIGTERM", + ); + assert.equal( + makeProviderFailure({ + cause: { _tag: "AcpProcessExitedError", code: NaN, signal: "unsafe signal" }, + }).message, + "ACP process exited", + ); +}); + +it("redacts prefixed credential fields and escaped quoted values in ACP stderr", () => { + const stderr = [ + "Synthetic configuration failed.", + "MISTRAL_API_KEY=FixtureOnly0123456789abcdefABCDEF", + "GEMINI_API_KEY=AIzaFixtureOnly0123456789", + JSON.stringify({ + refresh_token: "fixture-refresh-value", + client_secret: "fixture-client-secret", + private_key: "-----BEGIN PRIVATE KEY-----\nFIXTURE", + token: 'fixture"secret-tail', + }), + ].join("\n"); + const failure = makeProviderFailure({ + cause: { _tag: "AcpProcessExitedError", code: 23, stderr }, + }); + assert.include(failure.message, "Synthetic configuration failed."); + for (const value of [ + "FixtureOnly", + "AIzaFixture", + "fixture-refresh", + "fixture-client", + "FIXTURE", + "secret-tail", + ]) { + assert.notInclude(failure.message, value); + } +}); + +it("withholds whole ACP stderr lines containing credential markers", () => { + for (const line of [ + "AWS_SECRET_ACCESS_KEY=FixtureOnlyValue0123", + "SECRET_KEY=FixtureOnlyValue0123", + "STRIPE_SECRET_KEY=FixtureOnlyValue0123", + JSON.stringify({ clientSecret: "fixture-client-value", refreshToken: "fixture-refresh-value" }), + "secretAccessKey: FixtureOnlyValue", + "Authorization: token FixtureOnlyValue", + "Diagnostic only mentions a password; no value is present.", + "CLIENTSECRET=FixtureOnlyValue", + "GOOGLE_KEY=FixtureOnlyValue", + "Unknown configuration key; no credential is present.", + ]) { + const failure = makeProviderFailure({ + cause: { + _tag: "AcpProcessExitedError", + code: 23, + stderr: `Synthetic configuration failed.\n${line}\nFinal diagnostic.`, + }, + }); + assert.equal( + failure.message, + "ACP process exited with code 23\nSynthetic configuration failed.\n[REDACTED credential diagnostic]\nFinal diagnostic.", + ); + } +}); + +it("withholds multiline private-key bodies, including unterminated keys", () => { + for (const end of ["-----END PRIVATE KEY-----", ""]) { + const failure = makeProviderFailure({ + cause: { + _tag: "AcpProcessExitedError", + code: 23, + stderr: `Synthetic configuration failed.\n-----BEGIN PRIVATE KEY-----\nZml4dHVyZS1wcml2YXRlLWJvZHk=\n${end}`, + }, + }); + assert.include(failure.message, "ACP process exited with code 23"); + assert.notInclude(failure.message, "Zml4dHVyZS1wcml2YXRlLWJvZHk="); + } +}); + +it("redacts standalone service tokens in ACP exit diagnostics", () => { + const failure = makeProviderFailure({ + cause: { + _tag: "AcpProcessExitedError", + code: 23, + stderr: + "Synthetic failure: ghp_fixture0123456789 xoxb-fixture-0123456789 github_pat_fixture_123 gho_fixture123 ghu_fixture123 ghs_fixture123 ghr_fixture123", + }, + }); + assert.equal( + failure.message, + "ACP process exited with code 23\nSynthetic failure: [REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED]", + ); +}); + +it("redacts before bounding the tail of ACP exit diagnostics", () => { + const failure = makeProviderFailure({ + cause: { + _tag: "AcpProcessExitedError", + code: 23, + stderr: `api_key=${"fixturecredential".repeat(600)}\n${"old diagnostic\n".repeat(600)}Final synthetic diagnostic.`, + }, + }); + assert.isAtMost(failure.message.length, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH); + assert.isTrue(failure.message.startsWith("ACP process exited with code 23\n…")); + assert.isTrue(failure.message.endsWith("Final synthetic diagnostic.")); + assert.notInclude(failure.message, "fixturecredential"); +}); + +it("does not split a surrogate pair at the start of an ACP stderr tail", () => { + for (const suffix of ["Final.", "Final.!"]) { + const failure = makeProviderFailure({ + cause: { _tag: "AcpProcessExitedError", code: 23, stderr: `${"šŸš€".repeat(3_000)}${suffix}` }, + }); + assert.isAtMost(failure.message.length, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH); + assert.notMatch(failure.message, /[\uD800-\uDFFF]/u); + assert.isTrue(failure.message.endsWith(suffix)); + } +}); + it("redacts credentials whose key names end in a credential word", () => { const failure = makeProviderFailure({ code: '{"refresh_token":"code value"}', diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 2dd418ea2dfac..801ebcf1c0a4c 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -68,12 +68,13 @@ const PROVIDER_FAILURE_REASONS: Record = { /** * Walks a cause chain for curated text without reading arbitrary provider - * defect text. `known` is the message of a domain error T3 raised itself; + * defect text. `known` is a domain message or a redacted ACP exit diagnostic; * `category` is the generic message of the innermost provider wrapper seen. */ function curatedCauseMessages(cause: unknown): { readonly known: string | undefined; readonly category: string | undefined; + readonly code?: string | undefined; } { const seen = new Set(); let category: (typeof PROVIDER_FAILURE_CATEGORIES)[string]; @@ -94,6 +95,10 @@ function curatedCauseMessages(cause: unknown): { if (typeof cause !== "object") break; const tag = (cause as Record)._tag; switch (tag) { + case "AcpProcessExitedError": { + const diagnostic = acpProcessExitDiagnostic(cause as Record); + return { known: diagnostic.message, code: diagnostic.code, category: categoryMessage() }; + } case "ContextHandoffBudgetError": return { known: new ContextHandoffBudgetError().message, category: categoryMessage() }; case "ContextHandoffBackgroundWorkError": @@ -191,6 +196,7 @@ function replaceUnsafeControlCharacters(value: string): string { function redactProviderFailureText(value: string): string { return replaceUnsafeControlCharacters(value) + .replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*/giu, "[REDACTED private key]") .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl) .replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]") .replace( @@ -202,9 +208,50 @@ function redactProviderFailureText(value: string): string { "$1[REDACTED]", ) .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED]") + .replace( + /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/gu, + "[REDACTED]", + ) .trim(); } +function acpProcessExitDiagnostic(error: Record) { + const code = + typeof error.code === "number" && Number.isSafeInteger(error.code) + ? String(error.code) + : undefined; + const signal = + typeof error.signal === "string" && /^SIG[A-Z0-9]{1,16}$/.test(error.signal) + ? error.signal + : undefined; + const summary = signal + ? `ACP process exited after signal ${signal}` + : code === undefined + ? "ACP process exited" + : `ACP process exited with code ${code}`; + const stderr = + typeof error.stderr === "string" + ? redactProviderFailureText(error.stderr) + .split(/\r\n?|\n/u) + .map((line) => + /token|secret|pass(?:word|wd)|credential|authorization|key/u.test( + line.normalize("NFKC").toLowerCase(), + ) + ? "[REDACTED credential diagnostic]" + : line, + ) + .join("\n") + : ""; + const available = MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - summary.length - 1; + let start = Math.max(0, stderr.length - available + 1); + if (start > 0 && /[\uDC00-\uDFFF]/u.test(stderr[start] ?? "")) start += 1; + const excerpt = stderr.length > available ? `…${stderr.slice(start)}` : stderr; + return { + message: excerpt.length === 0 ? summary : `${summary}\n${excerpt}`, + code: code ?? signal, + }; +} + function boundedText(value: string, maxLength: number): string { const redacted = redactProviderFailureText(value); if (redacted.length <= maxLength) return redacted; @@ -228,7 +275,7 @@ export function makeProviderFailure(input: { const rawMessage = input.message ?? curated.known ?? curated.category ?? DEFAULT_PROVIDER_FAILURE_MESSAGE; const message = boundedText(rawMessage, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH); - const rawCode = input.code ?? stringField(input.cause, "code") ?? null; + const rawCode = input.code ?? curated.code ?? stringField(input.cause, "code") ?? null; const code = rawCode === null ? null : boundedText(rawCode, MAX_PROVIDER_FAILURE_CODE_LENGTH) || null;