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

+31
View File
@@ -415,6 +415,37 @@ values become `unknown`. A signal such as `SIGKILL` does not establish who sent
it or prove an out-of-memory kill. These fields do not change error grouping
or run outcomes, and do not include process output or adapter result payloads.
The shared reporter also attaches bounded diagnostic contexts for both legacy
and native runs:
- `run_execution`: runtime mode, execution stage/native phase, driver and version,
duration, failure phase, stop reason, error family, and timeout settings when available.
- `adapter_failure`: selected adapter error fields such as phase, category,
protocol code, retryability, cause message, stack preview, HTTP status, and request ID.
- `provider_failure`: the saved provider failure category, title, and details.
- `run_exception_0` through `run_exception_3`: exception names, codes, HTTP
statuses, and request IDs for a caught exception and up to three causes.
The execution and setup catch paths pass the original exception to the reporter.
It snapshots and rebuilds only these selected fields and the original message and
stack. Sentry receives the sanitized cause chain rather than a stack created at
the reporting call. Saved adapter results use an available adapter stack preview;
without one, the report has no synthetic reporting stack.
Credential patterns, the current user's home path, registered run-secret values,
and known host/runtime environment credentials are removed before truncation.
Declared environment secret bindings are included even when their key has no
credential-like name. Runtime values are used only for redaction and are never
copied into an event. Stack fields are limited to 8,192 characters,
exception messages to 2,048, provider titles to 4,096, and provider details to
12,288. Other diagnostic strings are limited to 200 characters (adapter cause
messages: 2,048). Truncation is marked in the text and in
`run_execution.truncatedFields`; cyclic and deeper cause chains are marked too.
Request/response objects, headers, environment/configuration, prompts, stdout,
stderr, and arbitrary adapter result fields are not copied. The existing DSN
opt-in gate and error-code/adapter fingerprint remain unchanged. These reports
cannot recover diagnostic data that a provider or adapter discarded upstream.
### Browser data
The browser sends no page URL, no referrer, no user agent, and no
@@ -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");