diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 27801cf59e4..32ca15536d3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -2088,6 +2088,23 @@ fn redact_sensitive_text_values(input: &str) -> String { .any(|delimiter| before.ends_with(delimiter)) }; let has_hyphenated_count_lead = token_phrase_has_lead("one-"); + // An explicitly literal/exact token is a requested text value (for + // example an acceptance identifier), not a diagnostic credential pair. + // Keep this grammatical exception separate from credential syntax: + // assignments, quoted values/keys, CLI and compound keys still redact, + // as do the independent Bearer, key-prefix and JWT scanners above. + let is_literal_token_reference = key == "token" + && !key_is_compound + && whitespace_start == start + key.len() + && separator > whitespace_start + && !has_assignment_separator + && bytes[whitespace_start..separator] + .iter() + .all(|value| matches!(value, b' ' | b'\t')) + && quoted_value_start(separator).1.is_none() + && ["literal ", "exact "] + .iter() + .any(|lead| token_phrase_has_lead(lead)); let is_benign_token_noun_phrase = key == "token" && (!key_is_compound || has_hyphenated_count_lead) && whitespace_start == start + key.len() @@ -2150,7 +2167,8 @@ fn redact_sensitive_text_values(input: &str) -> String { || (token_phrase_has_tail("can equal") && token_phrase_has_lead("one "))); let has_whitespace_separator = separator > whitespace_start && (key != "authorization" || key_is_compound || has_authorization_scheme) - && !is_benign_token_noun_phrase; + && !is_benign_token_noun_phrase + && !is_literal_token_reference; if !has_assignment_separator && !has_whitespace_separator { continue; } @@ -3159,6 +3177,68 @@ mod tests { assert_eq!(sanitized["accessToken"], json!("[REDACTED]")); } + #[test] + fn semantic_handoff_preserves_literal_acceptance_identifiers() { + let description = "The document body must contain the literal token CHAT250ed7e4dc071. No code changes needed."; + let plan = "## Plan\n- Include the literal token CHAT250ed7e4dc071 in the document body.\n- Save the output document."; + let input = json!({ + "title": "Write project description", + "description": description, + "initialPlan": plan, + "idempotencyKey": "write-description-1", + }); + assert_eq!( + sanitize_semantic_tool_input("create_task", &input).unwrap(), + input + ); + for text in [ + description, + plan, + "Must include the literal token `CHAT66e7813a4f9d1` somewhere in the text.", + "Include the exact token ACCEPTANCE-42 in the final output.", + ] { + assert_eq!(redact_text(text), text); + assert_eq!( + sanitize_value(&json!({"body": text})), + json!({"body": text}) + ); + } + } + + #[test] + fn literal_token_prose_does_not_exempt_credential_syntax_or_shapes() { + for text in [ + "auth token opaque-credential", + "the token opaque-credential", + "literal token=opaque-credential", + "literal token:opaque-credential", + "literal --token opaque-credential", + "literal access_token opaque-credential", + "literal \"token\" opaque-credential", + "literal token \"opaque-credential\"", + ] { + assert!(!redact_text(text).contains("opaque-credential"), "{text}"); + } + for secret in [ + "sk-proj-secretvalue123456", + "ghp_secretvalue12345678901234567890", + "eyJhbGciOiJIUzI1NiJ9.c2VjcmV0LWNsYWlt.signaturesecret", + ] { + let text = format!("Include the literal token {secret} in the document."); + assert!(!redact_text(&text).contains(secret), "{text}"); + } + let input = json!({ + "description": "Include the literal token ACCEPTANCE-42. Authorization: Bearer opaque-credential", + "token": "opaque-credential", + }); + let safe = sanitize_semantic_tool_input("create_task", &input).unwrap(); + assert!(safe["description"] + .as_str() + .unwrap() + .contains("ACCEPTANCE-42")); + assert!(!safe.to_string().contains("opaque-credential")); + } + #[test] fn semantic_redaction_preserves_benign_token_system_prose() { let prose = "Offer a simple token system so guests can exchange items even when their contributions differ in quantity."; diff --git a/tests/runner-e2e/chat-flow.test.ts b/tests/runner-e2e/chat-flow.test.ts index 1e7a715f8bb..5d8fdd8ed01 100644 --- a/tests/runner-e2e/chat-flow.test.ts +++ b/tests/runner-e2e/chat-flow.test.ts @@ -6,6 +6,7 @@ import { chatQuestionPresentation, chatRunFailure, chatTaskCompletionFailure, + createChatIdleFailureDetector, collectChatRunEvidence, readRunningChatLog, readChatOutputDocument, @@ -19,6 +20,7 @@ import type { RunnerApi } from "./api.js"; import { chatMarker } from "./chat-cases.js"; import { runnerMatrix } from "./catalog.js"; import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js"; +import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js"; const source: ChatIssue = { id: "chat", @@ -265,6 +267,28 @@ describe("chat acceptance contracts", () => { ), ).toBeUndefined(); }); + it("fails stable contradictory idle states promptly without paid retries or transient false alarms", () => { + const detect = createChatIdleFailureDetector(3); + const settled = { + resolved: true, + status: "blocked", + conversationState: "waiting", + providerRunCount: 3, + activeRuns: [] as string[], + }; + expect(detect(settled)).toBeUndefined(); + expect(detect({ ...settled, activeRuns: ["running"] })).toBeUndefined(); + expect(detect(settled)).toBeUndefined(); + const failure = detect(settled); + expect(failure).toContain("chat_idle_state_invariant"); + expect(classifyFailure(failure)).toBe("candidate_failure"); + expect(shouldRetryFailure(classifyFailure(failure))).toBe(false); + expect(detect({ ...settled, status: "in_review" })).toBeUndefined(); + expect(detect(settled)).toBeUndefined(); + expect(detect({ ...settled, providerRunCount: 2 })).toBeUndefined(); + expect(detect({ ...settled, status: "in_progress" })).toBeUndefined(); + expect(detect(settled)).toBeUndefined(); + }); it("fails promptly on terminal provider failures while permitting only expected cancellations", () => { expect(chatRunFailure([run])).toBeUndefined(); expect(chatRunFailure([{ ...run, status: "running" }])).toBeUndefined(); diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 69cba685f2f..13845246c71 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -126,6 +126,34 @@ export function chatTaskCompletionFailure( return chatRunFailure(runs); } +/** Require two settled observations so an in-flight finalization is not a failure. */ +export function createChatIdleFailureDetector(minimumProviderRuns: number) { + let priorInconsistentState: string | undefined; + return (state: { + resolved: boolean; + status?: string; + conversationState?: string; + providerRunCount: number; + activeRuns: string[]; + }): string | undefined => { + const inconsistentState = + state.resolved && + state.providerRunCount >= minimumProviderRuns && + state.activeRuns.length === 0 && + state.conversationState === "waiting" && + ["blocked", "done", "cancelled"].includes(state.status ?? "") + ? `${state.status}:${state.providerRunCount}` + : undefined; + const stable = + inconsistentState !== undefined && + inconsistentState === priorInconsistentState; + priorInconsistentState = inconsistentState; + return stable + ? `chat_idle_state_invariant: conversation is ${state.status} while waiting after ${state.providerRunCount} settled provider runs` + : undefined; + }; +} + /** Match the shared question form's durable/native presentation, including custom labels. */ export function chatQuestionPresentation( payload: AskUserQuestionsPayload, @@ -254,6 +282,7 @@ export async function runChatFlow(input: { a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id), ); const idle = async (minimumProviderRuns: number) => { + const inconsistentIdle = createChatIdleFailureDetector(minimumProviderRuns); await pollUntil({ label: "chat turn settles to waiting", deadlineAt: Date.now() + 240_000, @@ -274,7 +303,7 @@ export async function runChatFlow(input: { failure: chatRunFailure(runs, caseId === "stop-new-resume"), }; }, - reject: (state) => state.failure, + reject: (state) => state.failure ?? inconsistentIdle(state), accept: (state) => !state.failure && state.resolved && diff --git a/tests/runner-e2e/failure-classifier.ts b/tests/runner-e2e/failure-classifier.ts index ad4a58d662a..1dca091cb7a 100644 --- a/tests/runner-e2e/failure-classifier.ts +++ b/tests/runner-e2e/failure-classifier.ts @@ -29,6 +29,7 @@ export function classifyFailure(error: unknown): FailureClass { } if (PERMANENT.test(message)) return "permanent_infrastructure"; if ( + /chat_idle_state_invariant/.test(message) || NON_RETRYABLE_SESSION_CLOSE.test(message) || NON_RETRYABLE_ACPX_SESSION_OPEN.test(message) )