fix(sentry): preserve run failure stacks and diagnostic context (#14585)

Builds on merged #14575 and targets `master`.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use optional Sentry reports to diagnose failed agent runs.
> - The shared reporter currently converts each saved error message into
a new exception.
> - This loses the original stack and cause chain, and omits available
adapter and provider diagnostics.
> - This pull request selects, redacts, and bounds useful diagnostics
before it sends them to Sentry.
> - Operators can locate the failing operation and correlate upstream
failures without copying arbitrary run data.

## Linked Issues or Issue Description

Refs #14573. That change preserves ACP provider failure details in the
run record.
Refs #14575. That merged PR adds recorded exit codes and signals. This
PR covers stacks, causes, and structured diagnostics without duplicating
those fields.

A thrown setup or adapter error currently appears in Sentry with the
reporter's stack. A saved provider failure can contain useful details
that never reach the Sentry event. Both cases use the same shared
reporting path.

## What Changed

- Pass caught setup and execution exceptions, and structured adapter
error metadata, through successful terminal status transitions.
- Snapshot a fixed set of execution, adapter, provider, and exception
fields. Preserve up to four exceptions in the cause chain.
- Remove registered secret values, declared runtime environment
credentials, unknown inherited environment values and encoded credential
forms, and credential patterns before truncation. Mark truncated fields
and bound provider details and stacks.
- Rebuild sanitized Sentry exceptions with the original stacks and
causes. Use an adapter stack preview when available. Omit a fabricated
reporting stack when the source has no stack.
- Keep contexts local to each event. Preserve the optional DSN gate and
existing error-code/adapter fingerprint.
- Document the fields, limits, and omitted data.
- Settle leftover chat fixture outbox rows only after assertions and
worker shutdown, so subsequent tests cannot claim earlier cases’ pending
actions or provider I/O. This fixes the CI shard contamination exposed
during verification; production chat behavior and test timeouts are
unchanged.

## Verification

- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- 86 focused diagnostic, Sentry, and startup tests passed after the
rebase, including the real `@sentry/node@10.71.0` SDK with an in-memory
transport.
- Tests cover cause chains, HTTP status and request IDs, long provider
details, secret redaction, failed secret resolution, cyclic causes, size
limits, and context isolation.
- Five targeted heartbeat integration cases passed, including thrown and
returned errors containing an opaque environment-bound credential. The
earlier full heartbeat integration run also passed its integration
cases.
- The focused suite also passed with an unknown inherited environment
value set to `1`; reporting tests use a controlled environment and
separately verify short-secret redaction.
- Before the fixture cleanup, the full chat shard reproduced the CI
Telegram timeout at `pending recovery before restart` (354 passed, 1
failed). After the cleanup, the same shard passed all 355 tests. No
timeout or production behavior changed.
- Latest-head CI (`0ae9e70df321d66dda025c3a7ba4787e169e0a4f`) passed
typecheck, build, all 12 server shards, all 3 chat shards, workspace and
serialized suites, browser shards, Runner checks, canary validation, the
real Sentry SDK contract, and security checks. Required `ci / verify`
and `ci / e2e` passed.
- The branch has been rebased onto `master` after #14575 merged. All 55
applicable checks passed on this head (2 unrelated Storybook checks
skipped). Greptile re-reviewed the current 11-file diff at 5/5 with no
findings or unresolved review threads.
- Local monolithic full-suite attempts were interrupted to apply fixes;
full-suite success is not claimed from those runs.

## Risks

- Error messages and stacks can contain credentials. The reporter uses
existing redactors and the run's encrypted secret registry, reads only
known fields, and skips capture if registered-secret resolution fails.
- Unknown environment values remain private by default. Unrecognized
short values can mask benign matches; known public settings are
explicitly allowed.
- Diagnostic text is bounded and can be truncated. Truncation is
explicit. Data discarded upstream cannot be recovered.
- These additional fields go to the operator's configured Sentry
endpoint. Arbitrary request/response objects, headers, configuration,
prompts, and stdout/stderr are not copied.

## Model Used

OpenAI GPT-6 through Codex, with tool use and code execution. The exact
serving model ID, context window, and configured reasoning level are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-29 12:54:12 -05:00
1 parent 2de43fc909
commit b3eb03fcba
11 files changed
+667 -36

No files matched your search

@@ -1048,7 +1048,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
try {
await Promise.all([...fixtureServices].map((service) => service.shutdown()));
} finally {
await retireFixtureState([...fixtureCompanies]);
const companyIds = [...fixtureCompanies];
await retireFixtureState(companyIds);
if (companyIds.length > 0) {
// Pausing an endpoint does not remove its rows from global recovery
// selectors. After every assertion and worker shutdown, settle leftover
// fixture work so later cases cannot claim its leases or retry its I/O.
await db.update(chatActions).set({ status: "cancelled" })
.where(and(inArray(chatActions.companyId, companyIds), notInArray(chatActions.status, ["processed", "cancelled"])));
await db.update(chatDeliveries).set({ state: "failed", nextAttemptAt: null })
.where(and(inArray(chatDeliveries.companyId, companyIds), inArray(chatDeliveries.state, ["received", "processing", "retry"])));
await db.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null })
.where(and(inArray(chatPublications.companyId, companyIds), inArray(chatPublications.state, ["pending", "awaiting_consent", "streaming", "retry"])));
}
fixtureServices.clear();
fixtureCompanies.clear();
}
@@ -1565,8 +1565,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
"continuation_task_ownership_changed",
])("retains untyped continuation setup failures: %s", async (message) => {
const { runId, wakeupRequestId } = await seedQueuedIssueRunFixture();
const error = new Error(message, { cause: Object.assign(new Error("upstream setup failed"), { code: "ECONNRESET" }) });
const build = vi.spyOn(executionContinuation, "buildExecutionContinuation")
.mockRejectedValueOnce(new Error(message));
.mockRejectedValueOnce(error);
try {
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
@@ -1576,6 +1577,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(await heartbeat.getRun(runId)).toMatchObject({
status: "failed", errorCode: "setup_failed", error: message,
});
const report = await waitForValue(async () => mockCaptureRunFailure.mock.calls.find(([event]) => event.runId === runId)?.[0]);
expect(report?.diagnostics).toMatchObject({
execution: { failurePhase: "setup" },
exceptions: [{ message, stack: expect.stringContaining("heartbeat-process-recovery.test.ts") }, { code: "ECONNRESET" }],
});
const [wakeup] = await db.select().from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId));
expect(wakeup.status).toBe("failed");
@@ -4801,6 +4807,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(failedRun?.error).toContain(
"is not installed or its plugin worker is not running",
);
const report = await waitForValue(async () => mockCaptureRunFailure.mock.calls.find(([event]) => event.runId === runId)?.[0]);
expect(report?.diagnostics).toMatchObject({
execution: { failurePhase: "execute" },
exceptions: [{ stack: expect.stringContaining("heartbeat-process-recovery.test.ts") }],
});
const interaction = await waitForValue(async () => {
const row = await db
@@ -5282,6 +5293,38 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(validationComment).toBeTruthy();
});
it.each(["throw", "result"])("redacts opaque environment-bound credentials from Sentry diagnostics: %s", async (mode) => {
const { companyId, agentId, runId } = await seedQueuedIssueRunFixture();
const svc = secretService(db);
const value = "opaque-runtime-credential-fixture";
const secret = await svc.create(companyId, {
name: `sentry-redaction-${randomUUID()}`, provider: "local_encrypted", value,
});
const env = { CUSTOM_BINDING: { type: "secret_ref", secretId: secret.id, version: "latest" } };
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: agentId }, env);
await db.update(agents).set({ adapterConfig: { env } }).where(eq(agents.id, agentId));
mockAdapterExecute.mockImplementationOnce(async (input) => {
expect(input.config.env.CUSTOM_BINDING).toBe(value);
const message = `upstream rejected ${value}`;
if (mode === "throw") throw new Error(message, { cause: new Error(message) });
return {
exitCode: 1, signal: null, timedOut: false, errorMessage: message, errorCode: "adapter_failed",
errorMeta: { causeMessage: message },
resultJson: { terminalSessionFailure: { category: "service", details: message } },
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
await heartbeat.waitForRunExecutionDrain(runId);
const report = await waitForValue(async () => mockCaptureRunFailure.mock.calls.find(([event]) => event.runId === runId)?.[0]);
expect(report).toBeDefined();
expect(report.errorMessage).toContain("upstream rejected");
expect(JSON.stringify(report)).not.toContain(value);
if (mode === "throw") expect(report.diagnostics.exceptions).toHaveLength(2);
else expect(report.diagnostics.provider.category).toBe("service");
});
it("blocks before dispatch when a declared secret ref has no binding instead of emitting an opaque setup failure", async () => {
const { companyId, agentId, runId, issueId } =
await seedQueuedIssueRunFixture();
@@ -1,5 +1,7 @@
import { createRequire } from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
import { collectRunFailureDiagnostics, sanitizeRunFailureDiagnostics } from "../services/run-failure-diagnostics.js";
import type { heartbeatRuns } from "@paperclipai/db";
// Sentry is an optional peer. When installed, exercise the real SDK with an
// in-memory transport, including its context behavior without an OTel manager.
@@ -76,7 +78,17 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
exitCode: null,
signal: "SIGKILL",
};
captureRunFailure(first);
const original = Object.assign(new Error(first.errorMessage, { cause: Object.assign(new Error("upstream connection reset"), {
code: "ECONNRESET", requestId: "request-123", status: 503,
stack: "Error: upstream connection reset\n at socketRead (/app/provider.js:19:7)",
response: { body: "private-response" },
}) }), { stack: "Error: first run failed\n at originalAdapter (/app/adapter.js:42:7)", request: { headers: "private-headers" } });
const diagnostics = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics({
runtimeMode: "legacy", resultJson: { terminalSessionFailure: {
category: "service", title: "Provider failed", details: "d".repeat(9000),
} },
} as unknown as typeof heartbeatRuns.$inferSelect, { error: original, phase: "execute" }));
captureRunFailure({ ...first, diagnostics });
await Promise.resolve();
captureException(new Error("unrelated database error"));
captureRunFailure(second);
@@ -91,7 +103,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
expect(events).toHaveLength(5);
const captured = (message: string) => events.find((event) =>
(event.exception as { values: Array<{ value: string }> }).values[0]?.value === message,
(event.exception as { values: Array<{ value: string }> }).values.some((value) => value.value === message),
);
for (const run of [first, second]) {
expect(captured(run.errorMessage)).toMatchObject({
@@ -108,10 +120,25 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
fingerprint: [first.errorCode, first.agentAdapter],
});
expect(JSON.stringify(events)).not.toContain("private-signal-payload");
const firstCapture = captured(first.errorMessage)!;
const exceptions = (firstCapture.exception as { values: Array<Record<string, unknown>> }).values;
expect(exceptions).toHaveLength(2);
expect(JSON.stringify(exceptions)).toContain("originalAdapter");
expect(JSON.stringify(exceptions)).toContain("socketRead");
expect(JSON.stringify(exceptions)).not.toContain("captureRunFailure");
expect(JSON.stringify(captured(second.errorMessage)?.exception)).not.toContain("captureRunFailure");
expect(firstCapture).toMatchObject({ contexts: {
provider_failure: { category: "service", details: "d".repeat(9000) },
run_exception_1: { code: "ECONNRESET", requestId: "request-123", status: 503 },
} });
expect(JSON.stringify(events)).not.toContain("private-");
for (const message of ["unrelated database error", "unrelated filesystem error"]) {
const event = captured(message);
expect(event).toBeDefined();
expect(event).not.toHaveProperty("contexts.run_failure");
for (const context of ["run_execution", "adapter_failure", "provider_failure", "run_exception_0", "run_exception_1"]) {
expect(event).not.toHaveProperty(`contexts.${context}`);
}
expect(event).not.toHaveProperty("fingerprint");
for (const tag of ["run_id", "task_id", "error_code", "agent_adapter", "run_status"]) {
expect(event).not.toHaveProperty(`tags.${tag}`);
@@ -215,6 +215,31 @@ describe("captureRunFailure", () => {
expect((received as Error).message).toBe("boom");
});
it("uses sanitized original stacks and causes instead of the reporting stack", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent({ diagnostics: {
execution: { runtimeMode: "native", failurePhase: "setup" },
adapter: {}, provider: {}, truncatedFields: [],
exceptions: [
{ name: "TypeError", message: "setup failed", stack: "TypeError: setup failed\n at originalSetup (/app/setup.js:42:7)" },
{ name: "Error", message: "connection reset", stack: "Error: connection reset\n at socketRead (/app/network.js:9:4)", code: "ECONNRESET", requestId: "req-123" },
],
} }));
const [error, context] = captureException.mock.calls[0]!;
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({ name: "TypeError", message: "setup failed", cause: { message: "connection reset" } });
expect((error as Error).stack).toContain("originalSetup");
expect((error as Error).stack).not.toContain("captureRunFailure");
expect(context?.contexts.run_exception_1).toMatchObject({ code: "ECONNRESET", requestId: "req-123" });
expect(context?.contexts.run_execution).toMatchObject({ failurePhase: "setup" });
});
it("does not invent a reporter stack for a saved result with no original exception", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent());
expect((captureException.mock.calls[0]![0] as Error).stack).toBeUndefined();
});
it("does not throw and captures nothing when the gate is closed", async () => {
vi.resetModules();
const sentryModule = await import("../sentry.js");
+31 -1
View File
@@ -56,6 +56,7 @@
// `instrumentation.ts`.
import os from "node:os";
import type { RunFailureDiagnostics } from "./services/run-failure-diagnostics.js";
import { readBuildCommit } from "./build-commit.js";
import { checkExactPeerVersions } from "./peer-version-check.js";
import { resolveSentryDsns } from "./sentry-dsn.js";
@@ -132,6 +133,8 @@ export interface RunFailureEvent {
agentAdapter: string;
/** The run status that triggered this report. */
runStatus: RunFailureStatus;
/** Bounded, redacted diagnostics selected by the run failure reporter. */
diagnostics?: RunFailureDiagnostics;
/** Recorded process exit evidence, when available. Validated before capture. */
exitCode?: number | null;
signal?: string | null;
@@ -174,7 +177,33 @@ export function captureRunFailure(event: RunFailureEvent): void {
// Sentry's async scope isolation is absent when OTel setup is skipped.
// A withScope mutation can then persist into unrelated later captures.
// Pass these fields on this event only; do not mutate the ambient scope.
handle.captureException(new Error(event.errorMessage), {
const diagnostics = event.diagnostics;
const contexts: Record<string, Record<string, unknown>> = {};
if (diagnostics) {
contexts.run_execution = { ...diagnostics.execution, truncatedFields: diagnostics.truncatedFields };
if (Object.keys(diagnostics.adapter).length) contexts.adapter_failure = diagnostics.adapter;
if (Object.keys(diagnostics.provider).length) contexts.provider_failure = diagnostics.provider;
diagnostics.exceptions.forEach(({ name, code, status, requestId }, index) => {
contexts[`run_exception_${index}`] = { name, code, status, requestId };
});
}
// Rebuild only sanitized exception fields. Passing a raw SDK Error can
// serialize its request/response, headers, or other enumerable properties.
let cause: Error | undefined;
for (const entry of [...(diagnostics?.exceptions ?? [])].reverse()) {
const error: Error = new Error(entry.message ?? event.errorMessage, cause ? { cause } : undefined);
error.name = entry.name ?? "Error";
error.stack = entry.stack;
cause = error;
}
const exception = cause ?? new Error(event.errorMessage);
if (!cause) {
// A saved adapter result is not a thrown Error. Do not pretend that the
// reporter's own stack is the failure location.
exception.stack = typeof diagnostics?.adapter.stackPreview === "string"
? diagnostics.adapter.stackPreview : undefined;
}
handle.captureException(exception, {
tags: {
run_id: event.runId,
task_id: event.taskId,
@@ -183,6 +212,7 @@ export function captureRunFailure(event: RunFailureEvent): void {
run_status: event.runStatus,
},
contexts: {
...contexts,
run_failure: {
taskId: event.taskId,
runId: event.runId,
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import type { heartbeatRuns } from "@paperclipai/db";
import { collectRunFailureDiagnostics, collectRunFailureSecretValues, redactRunFailureSecretValues, sanitizeRunFailureDiagnostics, sanitizeRunFailureText } from "../run-failure-diagnostics.js";
type Run = typeof heartbeatRuns.$inferSelect;
const run = (overrides: Partial<Run> = {}) => ({ resultJson: null, ...overrides }) as Run;
const collect = (error: unknown) => collectRunFailureDiagnostics(run(), { error });
describe("run failure diagnostics", () => {
it("selects declared environment secrets under opaque names and common credential keys", () => {
expect(collectRunFailureSecretValues({
CUSTOM_BINDING: "bound-opaque-value", ACCESS_TOKEN: "plain-opaque-value", REGION: "us-east-1", EMPTY_KEY: "",
}, ["CUSTOM_BINDING"])).toEqual(["bound-opaque-value", "plain-opaque-value", "us-east-1"]);
expect(collectRunFailureSecretValues({ PATH: "/usr/bin", CUSTOM_BINDING: "opaque" }, [], true)).toEqual(["opaque"]);
expect(collectRunFailureSecretValues({ PATH: "secret-override" }, ["PATH"], true)).toEqual(["secret-override"]);
});
it("redacts encoded credentials and URL passwords without expanding replacement markers", () => {
const secret = 'opaque/"credential';
const values = collectRunFailureSecretValues({ DATABASE_URL: `postgres://user:${encodeURIComponent(secret)}@example/db` });
const result = redactRunFailureSecretValues({ text: `${secret} ${encodeURIComponent(secret)} ${JSON.stringify(secret)}`, status: 503 }, [...values, "REDACTED"]);
expect(result.text).not.toContain("opaque");
expect(result.status).toBe(503);
expect(result.text).not.toContain("***[REDACTED]");
});
it("preserves known boolean and numeric settings without exempting unknown or declared secrets", () => {
const env = { PAPERCLIP_DB_BACKUP_ENABLED: "false", PAPERCLIP_DB_BACKUP_RETENTION_DAYS: "1" };
expect(collectRunFailureSecretValues(env, [], true)).toEqual([]);
expect(collectRunFailureSecretValues(env, ["PAPERCLIP_DB_BACKUP_ENABLED"], true)).toEqual(["false"]);
expect(collectRunFailureSecretValues({ CUSTOM_BINDING: "1" }, [], true)).toEqual(["1"]);
expect(collectRunFailureSecretValues({ PAPERCLIP_DB_BACKUP_ENABLED: "opaque" }, [], true)).toEqual(["opaque"]);
});
it("preserves generic exceptions, numeric codes and nested network causes", () => {
const root = Object.assign(new Error("network failed"), { code: "ECONNRESET", statusCode: 502, request_id: "req-123" });
const outer = Object.assign(new TypeError("adapter failed", { cause: root }), { code: -32000 });
const result = sanitizeRunFailureDiagnostics(collect(outer));
expect(result.exceptions).toMatchObject([
{ name: "TypeError", message: "adapter failed", code: "-32000" },
{ name: "Error", message: "network failed", code: "ECONNRESET", status: 502, requestId: "req-123" },
]);
expect(result.exceptions[0].stack).toContain("run-failure-diagnostics.test.ts");
});
it("bounds cyclic, deep, and string causes without inspecting arbitrary error objects", () => {
const cyclic = new Error("cycle"); cyclic.cause = cyclic;
expect(collect(cyclic)).toMatchObject({ exceptions: [{ message: "cycle" }], truncatedFields: ["exceptions.cycle"] });
let error = new Error("leaf");
for (let i = 0; i < 10; i++) error = new Error(`level-${i}`, { cause: error });
expect(collect(error).exceptions).toHaveLength(4);
expect(collect(error).truncatedFields).toContain("exceptions.depth");
expect(collect(new Error("outer", { cause: "string cause" })).exceptions).toHaveLength(2);
const getter = Object.defineProperty({ message: "valid message" }, "response", { get: () => { throw new Error("must not read"); } });
expect(collect(getter).exceptions).toEqual([{ message: "valid message" }]);
expect(collect({ arbitrary: "private payload" }).exceptions).toEqual([]);
expect(collect(Object.defineProperty({}, "message", { get: () => { throw new Error("bad getter"); } })).exceptions).toEqual([]);
});
it("redacts credential forms before truncation in all diagnostic text", () => {
const failure = collectRunFailureDiagnostics(run({ resultJson: {
terminalSessionFailure: { category: "service", title: 'password="secret-password"', details: 'Authorization: Bearer secret-bearer' },
} }), { error: Object.assign(new Error('apiKey="secret-api-key"'), { cause: new Error('token="secret-token"') }), adapterErrorMeta: { causeMessage: 'password="secret-password"' } });
const result = sanitizeRunFailureDiagnostics(failure);
expect(JSON.stringify(result)).not.toContain("secret-");
expect(sanitizeRunFailureText('x'.repeat(90) + ' password="secret-at-cut-boundary"', 110)).not.toContain("secret-at");
expect(sanitizeRunFailureText('pass\x1b[31mword="secret-colored"', 200)).not.toContain("secret-colored");
});
it("reports truncation, preserves UTF-16 pairs, and keeps the total payload bounded", () => {
const huge = "😀".repeat(100_000);
let error = Object.assign(new Error(huge), { stack: huge });
for (let i = 0; i < 5; i++) error = Object.assign(new Error(huge, { cause: error }), { stack: huge });
const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: {
terminalSessionFailure: { category: "service", title: huge, details: huge, truncatedFields: ["details", "private-field"] },
} }), { error }));
expect(String(result.provider.title).length).toBeLessThanOrEqual(4096);
expect(String(result.provider.details).length).toBeLessThanOrEqual(12288);
expect(JSON.stringify(result)).not.toMatch(/[\ud800-\udfff]/u);
expect(result.truncatedFields).toContain("provider.details");
expect(result.truncatedFields).toContain("exceptions.0.stack");
expect(result.truncatedFields).not.toContain("provider.private-field");
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(150_000);
});
it("keeps scalar native and legacy execution evidence and rejects non-finite durations", () => {
const result = collectRunFailureDiagnostics(run({
runtimeMode: "native", nativePhase: "turn", driverKind: "codex", driverVersion: "1.2.3",
startedAt: new Date(1000), finishedAt: new Date(8500),
resultJson: { stopReason: "failed", timeoutFired: true, effectiveTimeoutSec: 60, errorFamily: "transient_upstream", summary: "private prose" },
}), { phase: "execute" });
expect(result.execution).toMatchObject({ runtimeMode: "native", nativePhase: "turn", driverKind: "codex", driverVersion: "1.2.3", durationMs: 7500, timeoutFired: true, effectiveTimeoutSec: 60, errorFamily: "transient_upstream" });
expect(JSON.stringify(result)).not.toContain("private prose");
expect(collectRunFailureDiagnostics(run({ startedAt: new Date(NaN), finishedAt: new Date() }), {}).execution).not.toHaveProperty("durationMs");
});
});
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRuns, type Db } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -9,6 +9,11 @@ import {
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
const mockRedactCurrentUserText = vi.hoisted(() => vi.fn());
const mockResolveSecret = vi.hoisted(() => vi.fn());
vi.mock("../../secrets/provider-registry.js", () => ({
getSecretProvider: () => ({ resolveVersion: mockResolveSecret }),
}));
vi.mock("../../sentry.js", () => ({
captureRunFailure: mockCaptureRunFailure,
@@ -23,8 +28,7 @@ vi.mock("../../log-redaction.js", async (importOriginal) => {
});
import { reportRunFailure, waitForPendingRunFailureReports } from "../run-failure-report.js";
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../../redaction.js";
import { redactCurrentUserText } from "../../log-redaction.js";
import { REDACTED_EVENT_VALUE } from "../../redaction.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -34,14 +38,29 @@ describeEmbeddedPostgres("reportRunFailure", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
let agentId!: string;
let inheritedEnv: NodeJS.ProcessEnv;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-failure-report-");
db = createDb(tempDb.connectionString);
}, 20_000);
beforeEach(() => {
// Unknown host values intentionally count as secrets, including short values
// such as "1". Keep these fixtures independent of the developer/CI environment
// and add secret values explicitly in the tests that exercise redaction.
inheritedEnv = process.env;
process.env = Object.fromEntries(
["PATH", "HOME", "USER", "USERNAME", "LOGNAME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"]
.flatMap((key) => inheritedEnv[key] === undefined ? [] : [[key, inheritedEnv[key]]]),
);
});
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
process.env = inheritedEnv;
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
@@ -141,6 +160,98 @@ describeEmbeddedPostgres("reportRunFailure", () => {
}
});
it("reports selected provider diagnostics and the original cause without copying arbitrary payloads", async () => {
await seedCompanyAndAgent();
const cause = Object.assign(new Error("provider unreachable"), {
code: "ECONNRESET", requestId: "request-123", status: 503,
response: { body: "private-response" },
});
const error = new Error("adapter threw", { cause });
const run = buildRun({
runtimeMode: "legacy",
executionStage: "execute",
error: "adapter threw",
stderrExcerpt: "private-stderr",
stdoutExcerpt: "private-stdout",
contextSnapshot: { prompt: "private-prompt" },
resultJson: {
terminalSessionFailure: { category: "service", title: "Provider unavailable", details: "request-123 failed", raw: "private-provider-raw" },
timeoutFired: false, summary: "private-summary", env: { SECRET: "private-env" },
},
});
await reportRunFailure(db, run, { error, phase: "execute", adapterErrorMeta: { phase: "turn", retryable: true, response: "private-adapter-response" } });
const captured = mockCaptureRunFailure.mock.calls[0][0];
expect(captured.diagnostics).toMatchObject({
execution: { runtimeMode: "legacy", executionStage: "execute", failurePhase: "execute", timeoutFired: false },
adapter: { phase: "turn", retryable: true },
provider: { category: "service", title: "Provider unavailable", details: "request-123 failed" },
exceptions: [{ message: "adapter threw" }, { message: "provider unreachable", code: "ECONNRESET", status: 503, requestId: "request-123" }],
});
expect(captured.diagnostics.exceptions[0].stack).toContain("run-failure-report.test.ts");
expect(JSON.stringify(captured)).not.toContain("private-");
expect(error.cause).toBe(cause);
});
it.each([false, true])("redacts registered run secrets and fails closed when resolution fails: %s", async (fails) => {
await seedCompanyAndAgent();
const secret = "opaque-registered-value";
const run = buildRun({
error: `connection failed: ${secret}`,
contextSnapshot: { paperclipSecretRedactions: [{ fingerprintSha256: "fixture", material: { encrypted: "fixture" } }] },
resultJson: { terminalSessionFailure: { category: "service", details: `upstream rejected ${secret}` } },
});
await db.insert(heartbeatRuns).values(run);
if (fails) mockResolveSecret.mockRejectedValueOnce(new Error("fixture resolution failed"));
else mockResolveSecret.mockResolvedValueOnce(secret);
await expect(reportRunFailure(db, run, {
error: new Error(`failed ${secret}`, { cause: new Error(`cause ${secret}`) }),
adapterErrorMeta: { causeMessage: `adapter ${secret}` },
})).resolves.toBeUndefined();
expect(mockResolveSecret).toHaveBeenCalledTimes(1);
if (fails) {
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
} else {
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
const captured = mockCaptureRunFailure.mock.calls[0][0];
expect(JSON.stringify(captured)).not.toContain(secret);
expect(captured.diagnostics.provider.details).toContain(REDACTED_EVENT_VALUE);
expect(captured.diagnostics.exceptions[1].message).toContain(REDACTED_EVENT_VALUE);
}
});
it("redacts runtime and host environment secret values even without a persisted registry", async () => {
await seedCompanyAndAgent();
const runtimeSecret = "opaque-runtime-value";
const hostSecret = "opaque-host-value";
vi.stubEnv("FIXTURE_CUSTOM_VALUE", hostSecret);
const text = `connection failed: ${runtimeSecret} ${hostSecret}`;
const run = buildRun({ error: text, contextSnapshot: null,
resultJson: { terminalSessionFailure: { details: text } },
});
await reportRunFailure(db, run, {
error: new Error(text, { cause: new Error(text) }),
adapterErrorMeta: { causeMessage: text, stackPreview: text },
secretValues: [runtimeSecret],
});
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
const captured = mockCaptureRunFailure.mock.calls[0][0];
expect(JSON.stringify(captured)).not.toContain(runtimeSecret);
expect(JSON.stringify(captured)).not.toContain(hostSecret);
expect(captured.diagnostics.exceptions).toHaveLength(2);
expect(captured).not.toHaveProperty("secretValues");
});
it("still redacts short values from unknown host settings", async () => {
await seedCompanyAndAgent();
vi.stubEnv("FIXTURE_CUSTOM_VALUE", "1");
await reportRunFailure(db, buildRun());
expect(mockCaptureRunFailure.mock.calls[0][0].errorMessage)
.toBe(`the provider process exited with code ${REDACTED_EVENT_VALUE}`);
});
it("captures nothing for succeeded, cancelled, and interrupted", async () => {
await seedCompanyAndAgent();
for (const status of ["succeeded", "cancelled", "interrupted"] as const) {
@@ -259,9 +370,10 @@ describeEmbeddedPostgres("reportRunFailure", () => {
await reportRunFailure(db, run);
const expectedErrorMessage = redactSensitiveText(redactCurrentUserText(rawError));
const { errorMessage } = mockCaptureRunFailure.mock.calls[0][0];
expect(errorMessage).toBe(expectedErrorMessage);
// Environment-value redaction can fully mask the username before the
// current-user redactor applies its partial mask. Both remove the home path.
expect(errorMessage).toContain("workspace/report.log");
expect(errorMessage).not.toContain(homeDir);
});
@@ -276,10 +388,13 @@ describeEmbeddedPostgres("reportRunFailure", () => {
const { errorMessage } = mockCaptureRunFailure.mock.calls[0][0];
expect(errorMessage).toHaveLength(MAX_ERROR_MESSAGE_LENGTH);
expect(errorMessage).toBe("x".repeat(MAX_ERROR_MESSAGE_LENGTH));
expect(errorMessage).toBe("x".repeat(MAX_ERROR_MESSAGE_LENGTH - 12) + "\n[truncated]");
});
it("does not change a short error message", async () => {
it("preserves a short error message with known public host settings", async () => {
vi.stubEnv("PAPERCLIP_DB_BACKUP_ENABLED", "false");
vi.stubEnv("PAPERCLIP_DB_BACKUP_RETENTION_DAYS", "1");
vi.stubEnv("GITHUB_RUN_ATTEMPT", "1");
await seedCompanyAndAgent();
const shortError = "the provider process exited with code 1";
const run = buildRun({ status: "failed", error: shortError });
@@ -326,7 +441,7 @@ describeEmbeddedPostgres("reportRunFailure", () => {
const { errorCode } = mockCaptureRunFailure.mock.calls[0][0];
expect(errorCode).toHaveLength(MAX_ERROR_CODE_LENGTH);
expect(errorCode).toBe("y".repeat(MAX_ERROR_CODE_LENGTH));
expect(errorCode).toBe("y".repeat(MAX_ERROR_CODE_LENGTH - 12) + "\n[truncated]");
});
it("sends errorCode null unchanged when the run holds no error code", async () => {
+17 -5
View File
@@ -326,6 +326,7 @@ import {
emitAgentTaskRunById,
} from "./agent-task-run-telemetry.js";
import { reportRunFailure } from "./run-failure-report.js";
import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService, type MissingRuntimeBinding } from "./secrets.js";
@@ -12754,12 +12755,13 @@ export function heartbeatService(
function emitTerminalAgentTaskRun(
updated: typeof heartbeatRuns.$inferSelect,
previousStatus: string | null,
failureReport?: RunFailureReportOptions,
) {
if (!isHeartbeatRunTerminalStatus(updated.status)) return;
if (previousStatus === updated.status) return;
clearHeartbeatRunRuntimeStatus(updated.id);
void emitAgentTaskRun(db, updated);
void reportRunFailure(db, updated);
void reportRunFailure(db, updated, failureReport);
}
async function setRunStatus(
@@ -12833,8 +12835,9 @@ export function heartbeatService(
runId: string,
status: string,
patch?: Partial<typeof heartbeatRuns.$inferInsert>,
failureReport?: RunFailureReportOptions,
) {
return setRunStatusFromLive(runId, status, ["running"], patch);
return setRunStatusFromLive(runId, status, ["running"], patch, failureReport);
}
// Move a run to a new status only when its current status is one of
@@ -12847,6 +12850,7 @@ export function heartbeatService(
status: string,
fromStatuses: string[],
patch?: Partial<typeof heartbeatRuns.$inferInsert>,
failureReport?: RunFailureReportOptions,
) {
// fromStatuses can name a terminal status as its own source (for example,
// an idempotent "still failed" patch), so the write below is not always a
@@ -12916,7 +12920,7 @@ export function heartbeatService(
payload: buildHeartbeatRunStatusLiveEventPayload(updated),
});
publishRunLifecyclePluginEvent(updated);
emitTerminalAgentTaskRun(updated, previousStatus?.status ?? null);
emitTerminalAgentTaskRun(updated, previousStatus?.status ?? null, failureReport);
return { run: updated, updated: true as const };
}
@@ -20238,6 +20242,7 @@ export function heartbeatService(
ReturnType<typeof traceStore.prepare>
> | null = null;
let providerTraceFinalized = false;
let readFailureReportSecrets: () => string[] = () => [];
try {
const agent = await getAgent(run.agentId);
@@ -21414,6 +21419,7 @@ export function heartbeatService(
secretsSvc,
trustPreset,
});
readFailureReportSecrets = () => collectRunFailureSecretValues(resolvedConfig.env, secretKeys);
if (aiBinding) {
try {
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
@@ -21504,6 +21510,11 @@ export function heartbeatService(
// Always replace this runtime-only field; caller wake data cannot supply skills.
context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions };
let runtimeConfig: Record<string, unknown> = connectorDelivery.config;
const resolvedFailureSecrets = readFailureReportSecrets();
readFailureReportSecrets = () => [
...resolvedFailureSecrets,
...collectRunFailureSecretValues(runtimeConfig.env, secretKeys),
];
const latestAgentConfigRevision = await getLatestAgentConfigRevision(
agent.companyId,
agent.id,
@@ -25191,6 +25202,7 @@ export function heartbeatService(
run.id,
status,
finalRunPatch,
{ adapterErrorMeta: adapterResult.errorMeta, secretValues: readFailureReportSecrets() },
);
let persistedRun: typeof heartbeatRuns.$inferSelect | null =
persistedRunWrite.run;
@@ -25830,7 +25842,7 @@ export function heartbeatService(
logBytes: logSummary?.bytes,
logSha256: logSummary?.sha256,
logCompressed: logSummary?.compressed ?? false,
});
}, { error: err, phase: "execute", secretValues: readFailureReportSecrets() });
if (
!failedRunWrite.updated &&
!(
@@ -26084,7 +26096,7 @@ export function heartbeatService(
: setupFailureResultJson
? { resultJson: setupFailureResultJson }
: {}),
}).catch(() => ({ run: null, updated: false as const }));
}, { error: outerErr, phase: "setup", secretValues: readFailureReportSecrets() }).catch(() => ({ run: null, updated: false as const }));
if (!setupFailureWrite.updated) {
logger.info(
{
@@ -0,0 +1,227 @@
import type { heartbeatRuns } from "@paperclipai/db";
import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction";
import { redactCurrentUserText } from "../log-redaction.js";
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
type Run = typeof heartbeatRuns.$inferSelect;
type Context = Record<string, string | number | boolean>;
export interface RunFailureReportOptions {
/** The caught exception, before callers flatten it to a message. */
error?: unknown;
/** Structured adapter error metadata; only known diagnostic keys are read. */
adapterErrorMeta?: unknown;
phase?: "setup" | "execute";
/** Runtime-only values to redact; never included in the captured event. */
secretValues?: readonly string[];
}
export interface RunFailureException {
name?: string;
message?: string;
stack?: string;
code?: string;
status?: number;
requestId?: string;
}
export interface RunFailureDiagnostics {
execution: Context;
adapter: Context;
provider: Context;
exceptions: RunFailureException[];
truncatedFields: string[];
}
function read(value: unknown, key: string): unknown {
if (!value || typeof value !== "object") return undefined;
try {
return (value as Record<string, unknown>)[key];
} catch {
return undefined;
}
}
function scalars(value: unknown, fields: readonly string[]): Context {
const result: Context = {};
for (const field of fields) {
const entry = read(value, field);
if (
typeof entry === "string" || typeof entry === "boolean" ||
(typeof entry === "number" && Number.isFinite(entry))
) result[field] = entry;
}
return result;
}
// Unknown inherited values may contain credentials under arbitrary names.
// Explicitly configured values remain private, including overrides of these keys.
const PUBLIC_ENV_KEYS = new Set([
"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE",
"HOMEDRIVE", "HOMEPATH", "USER", "USERNAME", "LOGNAME", "SHELL", "LANG",
"LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "NODE_ENV",
"XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME",
"PWD", "OLDPWD", "INIT_CWD", "SHLVL", "_", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM",
"NPM_LIFECYCLE_EVENT", "NPM_COMMAND", "NPM_EXEC_PATH", "NPM_EXECPATH", "NPM_NODE_EXECPATH",
"NPM_PACKAGE_NAME", "NPM_PACKAGE_VERSION", "NPM_PACKAGE_JSON", "NPM_CONFIG_USER_AGENT",
"XPC_SERVICE_NAME", "VITEST_POOL_ID", "VITEST_WORKER_ID",
"PAPERCLIP_AGENT_ID", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_TASK_ID",
]);
const PUBLIC_BOOLEAN_ENV_KEYS = new Set([
"OPENCODE_ALLOW_ALL_MODELS", "CLAUDE_CODE_USE_BEDROCK", "GOOGLE_GENAI_USE_GCA",
"CI", "NO_COLOR", "FORCE_COLOR",
"VITEST", "MALLOCNANOZONE", "CODEX_CI", "CODEX_SHELL", "CODEX_SAGE_BACKFILL_TRACKER_TAB_REUSE",
"DEV", "PROD", "SSR", "PAPERCLIP_REQUIRE_SENTRY_TEST_SDK",
"PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_AUTH_DISABLE_SIGN_UP", "PAPERCLIP_ENABLE_COMPANY_DELETION",
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "PAPERCLIP_SECRETS_STRICT_MODE",
"SERVE_UI", "PAPERCLIP_UI_DEV_MIDDLEWARE", "HEARTBEAT_SCHEDULER_ENABLED", "PAPERCLIP_ANNOUNCEMENTS_ENABLED",
"GITHUB_ACTIONS", "GITHUB_REF_PROTECTED", "RUNNER_DEBUG", "ACTIONS_RUNNER_DEBUG", "ACTIONS_STEP_DEBUG",
]);
const PUBLIC_NUMBER_ENV_KEYS = new Set([
"PORT", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", "PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
"PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "HEARTBEAT_SCHEDULER_INTERVAL_MS",
"GITHUB_RUN_ATTEMPT", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ID", "GITHUB_RETENTION_DAYS",
]);
/** Include declared bindings and unknown values, not just credential-like keys. */
export function collectRunFailureSecretValues(
env: unknown, secretKeys: Iterable<string> = [], inherited = false,
): string[] {
if (!env || typeof env !== "object") return [];
const strings = Object.fromEntries(Object.entries(env).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
));
const declared = new Set(secretKeys);
const values: string[] = [];
for (const [key, value] of Object.entries(strings)) {
if (!value) continue;
const publicSetting = (inherited && (
PUBLIC_ENV_KEYS.has(key.toUpperCase()) ||
(PUBLIC_NUMBER_ENV_KEYS.has(key.toUpperCase()) && /^\d+$/.test(value)) ||
(key === "BASE_URL" && value === "/") ||
(key === "MODE" && /^(?:test|development|production)$/.test(value))
)) ||
(PUBLIC_BOOLEAN_ENV_KEYS.has(key.toUpperCase()) && /^(?:0|1|true|false)$/i.test(value));
if (declared.has(key) || !publicSetting) values.push(value);
try {
const password = new URL(value).password;
if (password) values.push(value, password, decodeURIComponent(password));
} catch { /* ordinary environment values are not URLs */ }
}
return [...new Set(values)].sort((a, b) => b.length - a.length);
}
/** Redact literal, JSON-escaped and URI-encoded forms without rewriting markers. */
export function redactRunFailureSecretValues<T>(input: T, values: readonly string[]): T {
const forms = [...new Set(values.filter(Boolean).flatMap((value) => {
const forms = [value, JSON.stringify(value).slice(1, -1)];
try { forms.push(encodeURIComponent(value)); } catch { /* malformed Unicode */ }
return forms;
}))].sort((a, b) => b.length - a.length);
if (forms.length === 0) return input;
const pattern = new RegExp(forms.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gu");
const redact = (value: unknown): unknown => {
if (typeof value === "string") return value.replace(pattern, () => REDACTED_EVENT_VALUE);
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redact(entry)]));
}
return value;
};
return redact(input) as T;
}
/** Snapshot only diagnostics. Never walk a request, response, config or prompt. */
export function collectRunFailureDiagnostics(run: Run, options: RunFailureReportOptions): RunFailureDiagnostics {
const execution = scalars(run, [
"runtimeMode", "executionStage", "nativePhase", "driverKind", "driverVersion",
]);
if (options.phase) execution.failurePhase = options.phase;
if (run.startedAt && run.finishedAt) {
const durationMs = run.finishedAt.getTime() - run.startedAt.getTime();
if (Number.isFinite(durationMs) && durationMs >= 0) execution.durationMs = durationMs;
}
const result = run.resultJson;
Object.assign(execution, scalars(result, [
"mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured",
"effectiveTimeoutSec", "errorFamily",
]));
const adapter = scalars(options.adapterErrorMeta, [
"category", "phase", "errorName", "acpCode", "causeMessage", "retryable",
"stackPreview", "status", "statusCode", "requestId",
]);
const provider = scalars(read(result, "terminalSessionFailure"), ["category", "title", "details"]);
const truncatedFields: string[] = [];
const providerTruncation = read(read(result, "terminalSessionFailure"), "truncatedFields");
if (Array.isArray(providerTruncation)) {
for (const field of ["title", "details"]) {
if (providerTruncation.includes(field)) truncatedFields.push(`provider.${field}`);
}
}
const exceptions: RunFailureException[] = [];
const seen = new Set<unknown>();
let error = options.error;
while (error !== undefined && error !== null && exceptions.length < 4) {
if (seen.has(error)) {
truncatedFields.push("exceptions.cycle");
break;
}
seen.add(error);
if (typeof error === "string") {
exceptions.push({ message: error });
error = undefined;
break;
}
if (typeof error !== "object") break;
const entry: RunFailureException = {};
for (const field of ["name", "message", "stack", "code"] as const) {
const value = read(error, field);
if (typeof value === "string") entry[field] = value;
else if (field === "code" && typeof value === "number" && Number.isFinite(value)) entry.code = String(value);
}
const status = read(error, "status") ?? read(error, "statusCode");
if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599) entry.status = status;
const requestId = read(error, "requestId") ?? read(error, "request_id");
if (typeof requestId === "string") entry.requestId = requestId;
if (Object.keys(entry).length > 0) exceptions.push(entry);
else break;
error = read(error, "cause");
}
if (error !== undefined && error !== null && exceptions.length === 4) truncatedFields.push("exceptions.depth");
return { execution, adapter, provider, exceptions, truncatedFields };
}
/** Redact complete values before cutting, including credentials across a cut. */
export function sanitizeRunFailureText(input: string, limit: number): string {
const normalized = input
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
.replace(/[\ud800-\udfff]/gu, "\ufffd");
const clean = redactSensitiveText(redactDiagnosticText(redactCurrentUserText(normalized)));
if (clean.length <= limit) return clean;
const suffix = "\n[truncated]";
let end = limit - suffix.length;
if ((clean.codePointAt(end - 1) ?? 0) > 0xffff) end--;
return clean.slice(0, end) + suffix;
}
/** Called after the run's registered secret values have also been removed. */
export function sanitizeRunFailureDiagnostics(raw: RunFailureDiagnostics): RunFailureDiagnostics {
const truncatedFields = [...raw.truncatedFields];
const text = (value: string, path: string, limit: number) => {
const sanitized = sanitizeRunFailureText(value, limit);
if (sanitized.endsWith("\n[truncated]")) truncatedFields.push(path);
return sanitized;
};
const context = (value: Context, prefix: string, limits: Record<string, number> = {}): Context =>
Object.fromEntries(Object.entries(value).map(([key, value]) => [key,
typeof value === "string" ? text(value, `${prefix}.${key}`, limits[key] ?? 200) : value,
]));
return {
execution: context(raw.execution, "execution"),
adapter: context(raw.adapter, "adapter", { causeMessage: 2048, stackPreview: 8192 }),
provider: context(raw.provider, "provider", { title: 4096, details: 12288 }),
exceptions: raw.exceptions.map((entry, i) => context(entry as Context, `exceptions.${i}`, { message: 2048, stack: 8192 })),
truncatedFields: [...new Set(truncatedFields)],
};
}
+32 -18
View File
@@ -1,8 +1,14 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { agents, heartbeatRuns, type Db } from "@paperclipai/db";
import { captureRunFailure, type RunFailureStatus } from "../sentry.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { redactSensitiveText } from "../redaction.js";
import {
collectRunFailureDiagnostics,
collectRunFailureSecretValues,
redactRunFailureSecretValues,
sanitizeRunFailureDiagnostics,
sanitizeRunFailureText,
type RunFailureReportOptions,
} from "./run-failure-diagnostics.js";
import { logger } from "../middleware/logger.js";
type HeartbeatRun = typeof heartbeatRuns.$inferSelect;
@@ -23,15 +29,6 @@ const pendingRunFailureReports = new Set<Promise<void>>();
/** Bounds the shutdown wait, so one stuck report cannot hang the process exit. */
const PENDING_REPORT_DRAIN_TIMEOUT_MS = 5_000;
/**
* Remove a credential and the current user's home path from adapter-supplied
* text, then cut the result to `maxLength`. Run the length cut after the
* redaction, so a credential cannot survive at a cut boundary.
*/
function sanitizeAdapterText(input: string, maxLength: number): string {
return redactSensitiveText(redactCurrentUserText(input)).slice(0, maxLength);
}
function isRunFailureStatus(status: string): status is RunFailureStatus {
return status === "failed" || status === "timed_out";
}
@@ -53,10 +50,10 @@ function readTaskId(run: HeartbeatRun): string | null {
* its own in-flight promise, so a caller that does not await it still lets
* shutdown find and wait for the report — see `waitForPendingRunFailureReports`.
*/
export function reportRunFailure(db: Db, run: HeartbeatRun): Promise<void> {
export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureReportOptions = {}): Promise<void> {
if (!isRunFailureStatus(run.status)) return Promise.resolve();
const runStatus = run.status;
const report = captureTerminalRunFailure(db, run, runStatus);
const report = captureTerminalRunFailure(db, run, runStatus, options);
pendingRunFailureReports.add(report);
void report.finally(() => pendingRunFailureReports.delete(report));
return report;
@@ -66,12 +63,21 @@ async function captureTerminalRunFailure(
db: Db,
run: HeartbeatRun,
runStatus: RunFailureStatus,
options: RunFailureReportOptions,
): Promise<void> {
try {
const snapshot = redactRunFailureSecretValues({
errorMessage: run.error ?? "",
errorCode: run.errorCode ?? null,
diagnostics: collectRunFailureDiagnostics(run, options),
}, [...new Set([
...collectRunFailureSecretValues(process.env, [], true),
...(options.secretValues ?? []),
])].sort((a, b) => b.length - a.length));
const agent = await db
.select({ adapterType: agents.adapterType })
.from(agents)
.where(eq(agents.id, run.agentId))
.where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
const taskId = readTaskId(run);
@@ -80,18 +86,26 @@ async function captureTerminalRunFailure(
return;
}
// Resolve registered values before truncation. A failed resolution must
// not send an incompletely redacted report.
let redacted = snapshot;
if (Array.isArray(run.contextSnapshot?.paperclipSecretRedactions)) {
const { createRunSecretRedactionRegistry } = await import("./run-secret-redaction.js");
redacted = await createRunSecretRedactionRegistry(db).redactForRun(run.companyId, run.id, snapshot);
}
captureRunFailure({
taskId,
runId: run.id,
errorMessage: sanitizeAdapterText(run.error ?? "", MAX_ERROR_MESSAGE_LENGTH),
errorMessage: sanitizeRunFailureText(redacted.errorMessage, MAX_ERROR_MESSAGE_LENGTH),
errorCode:
run.errorCode === null
redacted.errorCode === null
? null
: sanitizeAdapterText(run.errorCode, MAX_ERROR_CODE_LENGTH),
: sanitizeRunFailureText(redacted.errorCode, MAX_ERROR_CODE_LENGTH),
agentAdapter: agent?.adapterType ?? UNKNOWN_ADAPTER,
runStatus,
exitCode: run.exitCode,
signal: run.signal,
diagnostics: sanitizeRunFailureDiagnostics(redacted.diagnostics),
});
} catch (err) {
logger.warn({ err, runId: run.id }, "failed to report run failure to Sentry");