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 1201c84a4c7..92f35e6db21 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 @@ -2052,6 +2052,9 @@ fn validate_semantic_tool_input_digest( } pub(crate) fn redact_text(input: &str) -> String { + // Cutting a secret first leaves an unmatchable plaintext fragment. + let redacted = redact_sensitive_text_values(input); + let input = redacted.as_str(); let (bounded, truncated) = if input.len() > 4096 { let boundary = input .char_indices() @@ -2063,7 +2066,7 @@ pub(crate) fn redact_text(input: &str) -> String { } else { (input, false) }; - let mut redacted = redact_sensitive_text_values(bounded); + let mut redacted = bounded.to_owned(); if truncated { redacted.push_str("…[truncated]"); } @@ -4263,6 +4266,42 @@ mod tests { ); } + #[test] + fn identity_redaction_precedes_multibyte_truncation() { + let prefix = "MC4CAQAwBQYDK2VwBCIEIA"; + let body = format!("{prefix}{}", "A".repeat(64 - prefix.len())); + let key = format!("-----BEGIN PRIVATE KEY-----\n{body}\n-----END PRIVATE KEY-----\n"); + // Give only this subprocess the key; do not race other tests' environment. + if std::env::var_os("PAPERCLIP_IDENTITY_TRUNCATION_TEST").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "durable::state::tests::identity_redaction_precedes_multibyte_truncation", + "--nocapture", + ]) + .env("PAPERCLIP_IDENTITY_TRUNCATION_TEST", "1") + .env("PAPERCLIP_AGENT_PRIVATE_KEY", &key) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + for material in [&body, &key] { + let input = format!("{}{material}", "é".repeat(2025)); + let output = redact_text(&input); + assert!(!output.contains(prefix)); + assert!(output.contains("[REDACTED]")); + assert!(output.len() <= 4096); + let persisted = sanitize_value(&json!({"text": input})); + assert!(!persisted.to_string().contains(prefix)); + } + } + #[test] fn diagnostic_redaction_still_bounds_retained_text() { let output = redact_text(&format!("token=secret-value {}", "x".repeat(4_096))); diff --git a/server/src/__tests__/agent-identity.test.ts b/server/src/__tests__/agent-identity.test.ts index 97dbdb5daf6..dcdbf1189b2 100644 --- a/server/src/__tests__/agent-identity.test.ts +++ b/server/src/__tests__/agent-identity.test.ts @@ -172,6 +172,28 @@ describe("agent cryptographic identity", () => { } }, 60_000); + it("persists ordinary stdout and stderr endings after settling the identity buffer", async () => { + const agent = await oldAgent(); + await db.update(agents).set({ adapterType: "process", adapterConfig: { + command: process.execPath, cwd: directory, + args: ["-e", "process.stdout.write('safe-'); process.stderr.write('safeM');"], + } }).where(eq(agents.id, agent.id)); + const heartbeat = heartbeatService(db); + try { + const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual"); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const finished = await heartbeat.getRun(run!.id); + expect(finished?.status).toBe("succeeded"); + expect(finished?.stdoutExcerpt).toMatch(/safe-$/); + expect(finished?.stderrExcerpt).toMatch(/safeM$/); + const lines = (await heartbeat.readLog(run!.id)).content.trim().split("\n").map(line => JSON.parse(line)); + expect(lines.filter(line => line.stream === "stdout").map(line => line.chunk).join("")).toContain("safe-"); + expect(lines.filter(line => line.stream === "stderr").map(line => line.chunk).join("")).toContain("safeM"); + } finally { + await drainHeartbeatRunsToQuiescence(db, heartbeat); + } + }, 60_000); + it("disaster recovery retains identity; both development seed modes omit it and mint fresh keys", async () => { const agent = await oldAgent(); const original = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 43c7295da77..6a47fc592e7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -23446,9 +23446,9 @@ export function heartbeatService( const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); - const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => { const sanitizedChunk = compactRunLogChunk( - redactCurrentUserText(identityRedactor.chunk(stream, chunk), currentUserRedactionOptions), + redactCurrentUserText(chunk, currentUserRedactionOptions), ); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); @@ -23521,6 +23521,8 @@ export function heartbeatService( }, }); }; + const onLog = (stream: "stdout" | "stderr", chunk: string) => + appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk)); if (runScopedMentionedSkillKeys.length > 0) { await onLog( "stdout", @@ -25169,7 +25171,7 @@ export function heartbeatService( adapterResult = identityRedactor.redact(adapterResult); for (const stream of ["stdout", "stderr"] as const) { const tail = identityRedactor.finish(stream); - if (tail) await onLog(stream, tail); + if (tail) await appendIdentityRedactedLog(stream, tail); } if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave }; adapterResult = applyWorkspaceRestoreFailure(adapterResult);