diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 6fd6bd36a2..adcc57ed69 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -241,6 +241,13 @@ section in the Observability contract. Provider identity diagnostics remain in the local run log. They record the notification method, expected and received thread/turn identifiers, and the classification (root, verified descendant, stale, unrelated informational, or invalid authoritative). They omit the original provider payload and credentials. Repeated informational notices are bounded. +Ignored unrelated Codex notifications use `harness.diagnostic` with code +`codex_unrelated_information`. The payload retains only the bounded provider +method and expected/received thread and turn identifiers. Account updates, skill +changes, and unrelated thread information do not create a provider notice in +chat. Chat also omits the matching notice stored by older runners. Real provider +warnings and errors remain visible. + Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry. If execution-continuation setup finds that a task no longer exists, is closed, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index e0eb95f611..759f5ef4c1 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -812,6 +812,25 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec + { + push( + &mut events, + "harness.diagnostic", + EventPriority::P1, + json!({ + "code": "codex_unrelated_information", + "classification": "unrelated_information", + "providerMethod": params.get("providerMethod").and_then(Value::as_str).map(|value| bounded_text(value, 160)), + "expectedThreadId": params.get("expectedThreadId").and_then(Value::as_str).map(|value| bounded_text(value, 256)), + "receivedThreadId": params.get("receivedThreadId").and_then(Value::as_str).map(|value| bounded_text(value, 256)), + "expectedTurnId": params.get("expectedTurnId").and_then(Value::as_str).map(|value| bounded_text(value, 256)), + "receivedTurnId": params.get("receivedTurnId").and_then(Value::as_str).map(|value| bounded_text(value, 256)), + }), + ) + } "error" | "warning" | "deprecationNotice" | "configWarning" => push( &mut events, "provider.notice.recorded", @@ -1421,6 +1440,68 @@ fn has_rfc_uri_scheme_prefix(value: &str) -> bool { mod tests { use super::*; + #[test] + fn unrelated_information_retains_only_bounded_run_log_diagnostics() { + let events = normalize_codex_notification( + "warning", + &json!({ + "classification": "unrelated_information", + "message": "ignored unrelated provider information", + "providerMethod": "account/updated", + "expectedThreadId": "root", + "receivedThreadId": "x".repeat(300), + "expectedTurnId": "turn-1", + "receivedTurnId": null, + "accessToken": "not-for-the-log", + "planType": "private-account-data", + }), + ); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "harness.diagnostic"); + assert_eq!(events[0].priority, EventPriority::P1); + assert_eq!( + events[0].payload, + json!({ + "code": "codex_unrelated_information", + "classification": "unrelated_information", + "providerMethod": "account/updated", + "expectedThreadId": "root", + "receivedThreadId": format!("{}…[truncated]", "x".repeat(244)), + "expectedTurnId": "turn-1", + "receivedTurnId": null, + }) + ); + let unicode_events = normalize_codex_notification( + "warning", + &json!({ + "classification": "unrelated_information", + "expectedThreadId": "token=not-for-the-log", + "receivedThreadId": "😀".repeat(300), + }), + ); + assert_eq!( + unicode_events[0].payload["expectedThreadId"], + "token=[REDACTED]" + ); + assert_eq!( + unicode_events[0].payload["receivedThreadId"], + format!("{}…[truncated]", "😀".repeat(244)) + ); + assert_eq!(unicode_events[0].payload["receivedTurnId"], Value::Null); + // Authoritative errors must retain their failure meaning. + assert_eq!( + normalize_codex_notification( + "error", + &json!({ + "classification": "unrelated_information", + "message": "Provider connection failed", + }) + )[0] + .event_type, + "provider.notice.recorded" + ); + } + #[test] fn preserves_codex_notice_text_from_current_and_legacy_payloads() { for method in ["configWarning", "deprecationNotice", "warning"] { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index 601fb38394..9550fb2e95 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -617,6 +617,14 @@ fn codex_account_updates_do_not_interrupt_turns_or_publish_account_details() { assert!(!params.to_string().contains("fixture-login")); assert!(params.get("authMode").is_none()); assert!(params.get("planType").is_none()); + let normalized = normalize_codex_notification(&method, ¶ms); + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].event_type, "harness.diagnostic"); + assert_eq!(normalized[0].payload["code"], "codex_unrelated_information"); + assert_eq!( + normalized[0].payload["providerMethod"], + params["providerMethod"] + ); } if method == "turn/completed" { completed = true; diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts index 739f021293..96d277dcef 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts @@ -59,6 +59,14 @@ describe("Codex app-server transport limits", () => { .toBe("Basic API foundation"); }); + it.each(["PAPERCLIP_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "CUSTOM_API_KEY"])( + "redacts the complete %s environment value through the shared text helper", + (key) => { + expect(redactCodexDiagnostic(`${key}=private;still-private`)) + .toBe(`${key}=[REDACTED]`); + }, + ); + it("reports restart-safe process-group ownership", async () => { const transport = nodeTransport("process.stdin.resume()", { processGroup: true }); const info = transport.processInfo(); diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 0b06817a53..7c86f2bb35 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -2,6 +2,9 @@ import type { NativeTurnControlCapabilities } from "../../contracts/types.js"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; import { githubCredentialEnvironment } from "../../github-credential-environment.js"; +import { redactCodexDiagnostic } from "./diagnostic-redaction.js"; + +export { redactCodexDiagnostic } from "./diagnostic-redaction.js"; export interface CodexRpcNotification { method: string; @@ -243,40 +246,6 @@ export function sanitizedEnvironmentKeys( return Object.keys(createSanitizedCodexEnvironment(source)).sort(); } -export function redactCodexDiagnostic(message: string): string { - return message - .replaceAll(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, "Bearer [REDACTED]") - .replace(/Basic\s+([A-Za-z0-9+/=]+)/gi, (match, encoded: string) => { - try { - // Only redact an actual RFC 7617 credential. Treating every word after - // “Basic” as base64 corrupted ordinary question copy such as - // “Basic API” before it entered the Paperclip protocol. - const decoded = Buffer.from(encoded, "base64").toString("utf8"); - return decoded.includes(":") ? "Basic [REDACTED]" : match; - } catch { - return match; - } - }) - .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@") - .replace( - /([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi, - "$1[REDACTED]", - ) - .replace( - /(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi, - "$1[REDACTED]", - ) - .replace( - /(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi, - "$1=[REDACTED]", - ) - .replace( - /(PAPERCLIP_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY)=[^\s]+/g, - "$1=[REDACTED]", - ); -} - function proxyContainsCredentials(value: string): boolean { try { const url = new URL(value); diff --git a/packages/paperclip-runner/src/drivers/codex/diagnostic-redaction.ts b/packages/paperclip-runner/src/drivers/codex/diagnostic-redaction.ts new file mode 100644 index 0000000000..856b9365e4 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/diagnostic-redaction.ts @@ -0,0 +1,33 @@ +/** Redact diagnostic text without importing the provider process or its credentials. */ +export function redactCodexDiagnostic(message: string): string { + return message + .replaceAll(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, "Bearer [REDACTED]") + .replace(/Basic\s+([A-Za-z0-9+/=]+)/gi, (match, encoded: string) => { + try { + // Only redact an actual RFC 7617 credential. Ordinary prose such as + // “Basic API” must remain readable. + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + return decoded.includes(":") ? "Basic [REDACTED]" : match; + } catch { + return match; + } + }) + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@") + .replace( + /([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi, + "$1[REDACTED]", + ) + .replace( + /(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi, + "$1[REDACTED]", + ) + .replace( + /(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi, + "$1=[REDACTED]", + ) + .replace( + /((?:[A-Z][A-Z0-9]*_)+API_KEY)=[^\s]+/g, + "$1=[REDACTED]", + ); +} diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index c395477b41..043399c5e5 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -34,6 +34,48 @@ function envelope( } describe("provider-neutral events", () => { + it("keeps unrelated information as bounded run-log evidence without a provider notice", () => { + const [event] = canonicalProviderEventsFromCodex("warning", { + classification: "unrelated_information", + message: "ignored unrelated provider information", + providerMethod: "account/updated", + expectedThreadId: "root", + receivedThreadId: "x".repeat(300), + expectedTurnId: "turn-1", + receivedTurnId: null, + accessToken: "not-for-the-log", + planType: "private-account-data", + }); + expect(event).toEqual({ + eventType: "harness.diagnostic", + itemId: "provider-item", + payload: { + code: "codex_unrelated_information", + classification: "unrelated_information", + providerMethod: "account/updated", + expectedThreadId: "root", + receivedThreadId: "x".repeat(244) + "…[truncated]", + expectedTurnId: "turn-1", + receivedTurnId: null, + }, + }); + expect(validatePrpEvent(envelope(event)).ok).toBe(true); + const [unicodeEvent] = canonicalProviderEventsFromCodex("warning", { + classification: "unrelated_information", + expectedThreadId: "token=not-for-the-log", + receivedThreadId: "😀".repeat(300), + }); + expect(unicodeEvent.payload).toMatchObject({ + expectedThreadId: "token=[REDACTED]", + receivedThreadId: "😀".repeat(244) + "…[truncated]", + receivedTurnId: null, + }); + expect(canonicalProviderEventsFromCodex("error", { + classification: "unrelated_information", + message: "Provider connection failed", + })[0].eventType).toBe("provider.notice.recorded"); + }); + it("preserves Codex notice summaries with legacy and empty-message fallbacks", () => { for (const method of ["configWarning", "deprecationNotice", "warning"]) { for (const [params, expected] of [ diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index 360a70c96d..e163706c53 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { redactCodexDiagnostic } from "./drivers/codex/diagnostic-redaction.js"; /** * Structural subset of an ACP runtime event consumed by the canonical event @@ -593,6 +594,29 @@ export function canonicalProviderEventsFromCodex( const type = text(item.type); const itemId = safeId(text(item.id, text(params.itemId)), "provider-item"); const completed = method === "item/completed"; + if (method === "warning" && params.classification === "unrelated_information") { + const boundedField = (key: string, limit: number) => { + if (typeof params[key] !== "string") return null; + const characters = [...redactCodexDiagnostic(params[key])]; + const marker = "…[truncated]"; + return characters.length <= limit + ? characters.join("") + : characters.slice(0, limit - marker.length).join("") + marker; + }; + return [{ + eventType: "harness.diagnostic", + itemId, + payload: { + code: "codex_unrelated_information", + classification: "unrelated_information", + providerMethod: boundedField("providerMethod", 160), + expectedThreadId: boundedField("expectedThreadId", 256), + receivedThreadId: boundedField("receivedThreadId", 256), + expectedTurnId: boundedField("expectedTurnId", 256), + receivedTurnId: boundedField("receivedTurnId", 256), + }, + }]; + } if (method === "turn/plan/updated") { const turnPlanId = safeId(text(params.turnId), "turn-plan"); return [ diff --git a/ui/src/adapters/paperclip-runner/index.test.ts b/ui/src/adapters/paperclip-runner/index.test.ts index 0f447cb4fe..e4c7354826 100644 --- a/ui/src/adapters/paperclip-runner/index.test.ts +++ b/ui/src/adapters/paperclip-runner/index.test.ts @@ -2,6 +2,33 @@ import { describe, expect, it } from "vitest"; import { paperclipRunnerUIAdapter } from "./index"; describe("paperclip runner transcript projection", () => { + it("omits unrelated provider diagnostics and legacy notices from streaming chat", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, payload }, + }), "2026-10-02T12:00:00.000Z"); + const legacyNotice = { + schema: "paperclip.provider.notice.v1", + severity: "warning", + category: "warning", + summary: "ignored unrelated provider information", + userActionable: true, + }; + expect(event("harness.diagnostic", { code: "codex_unrelated_information" })).toEqual([]); + expect(event("provider.notice.recorded", legacyNotice)).toEqual([]); + expect(event("provider.notice.recorded", { ...legacyNotice, summary: "Repository is not trusted" })) + .toEqual([expect.objectContaining({ family: "provider_notice" })]); + expect(event("provider.notice.recorded", { ...legacyNotice, severity: "error" })) + .toEqual([expect.objectContaining({ family: "provider_notice" })]); + expect(event("provider.notice.recorded", { ...legacyNotice, category: "configWarning" })) + .toEqual([expect.objectContaining({ family: "provider_notice" })]); + expect(event("harness.diagnostic", { code: "provider_identity_failure", message: "Thread mismatch" })) + .toEqual([expect.objectContaining({ kind: "system", text: "Runner: Thread mismatch" })]); + expect(event("item.completed", { kind: "agentMessage", channel: "final", text: "Here is the answer." })) + .toEqual([expect.objectContaining({ kind: "assistant", text: "Here is the answer." })]); + }); + it("renders committed PRP semantic tool items with the existing chat parts", () => { const started = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ type: "paperclip.prp.event", diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index 6d2ac167b7..ad1f8ef6c5 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -2,6 +2,7 @@ import type { PaperclipQuestion, PaperclipQuestionResponse, PaperclipQuestionSet import type { UIAdapterModule } from "../types"; import { parseCodexStdoutLine, buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui"; import { CodexLocalConfigFields } from "../codex-local/config-fields"; +import { isRunLogOnlyProviderEvent } from "@/components/transcript/run-log-only-events"; type JsonRecord = Record; @@ -671,6 +672,7 @@ function parsePrpEvent( ): TranscriptEntry[] { const eventType = text(event.eventType); const payload = record(event.payload); + if (isRunLogOnlyProviderEvent(eventType, payload)) return []; const family = eventType.startsWith("plan.") ? "plan" : eventType.startsWith("tool.execution.") ? "tool_execution" : eventType.startsWith("research.") ? "research" diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 754bd43348..7fba1ff24c 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -73,6 +73,31 @@ function runResult(summary: string): Record { } describe("provider notice presentation", () => { + it("omits unrelated information from saved chat without removing warnings or responses", () => { + const legacyNotice = { + schema: "paperclip.provider.notice.v1", + noticeId: "codex-warning", + severity: "warning", + category: "warning", + summary: "ignored unrelated provider information", + userActionable: true, + }; + const entries = nativeRunEventsToTranscript([ + event(1, "provider.notice.recorded", legacyNotice), + event(2, "harness.diagnostic", { code: "codex_unrelated_information" }), + event(3, "provider.notice.recorded", { ...legacyNotice, summary: "Repository is not trusted" }), + event(4, "provider.notice.recorded", { ...legacyNotice, severity: "error" }), + event(5, "provider.notice.recorded", { ...legacyNotice, category: "configWarning" }), + event(6, "item.completed", { kind: "agentMessage", channel: "final", text: "Here is the answer." }), + ]); + expect(entries).toMatchObject([ + { kind: "provider_activity", summary: "Repository is not trusted" }, + { kind: "provider_activity", status: "failed", summary: legacyNotice.summary }, + { kind: "provider_activity", summary: legacyNotice.summary }, + { kind: "assistant", text: "Here is the answer." }, + ]); + }); + it("preserves notice text as a notice rather than a synthetic tool call", () => { const entries = nativeRunEventsToTranscript([ event(1, "provider.notice.recorded", { diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index fcc4bb13e9..954a3ce785 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -1,5 +1,6 @@ import type { HeartbeatRunEvent } from "@paperclipai/shared"; import type { TranscriptEntry } from "@/adapters"; +import { isRunLogOnlyProviderEvent } from "./run-log-only-events"; function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -810,6 +811,7 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] // Notices are provider diagnostics, not tool calls. Preserve their message // and category for the shared notice row instead of serializing an input blob. if (event.eventType === "provider.notice.recorded" && payload.schema === "paperclip.provider.notice.v1") { + if (isRunLogOnlyProviderEvent(event.eventType, payload)) continue; entries.push({ kind: "provider_activity", ts, diff --git a/ui/src/components/transcript/run-log-only-events.ts b/ui/src/components/transcript/run-log-only-events.ts new file mode 100644 index 0000000000..7f51548e40 --- /dev/null +++ b/ui/src/components/transcript/run-log-only-events.ts @@ -0,0 +1,16 @@ +/** Internal provider bookkeeping belongs in run logs, including older notice records. */ +export function isRunLogOnlyProviderEvent( + eventType: string, + payload: Record, +): boolean { + if (eventType === "harness.diagnostic") { + return payload.code === "codex_unrelated_information"; + } + // Older runners dropped the classification when converting this diagnostic + // into a notice. Match its complete notice shape so real warnings stay visible. + return eventType === "provider.notice.recorded" + && payload.schema === "paperclip.provider.notice.v1" + && payload.severity === "warning" + && payload.category === "warning" + && payload.summary === "ignored unrelated provider information"; +}