fix(agents): preserve sessions_spawn transcript payloads (#82203)

Remove the transcript redaction path for sessions_spawn arguments and inline attachments. OpenClaw transcripts are local trusted-operator state, and streamTo/resumeSessionId are runtime routing fields that must not be rewritten before replay or dispatch.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
martingarramon
2026-05-26 17:30:01 -03:00
committed by GitHub
parent 60bec8c020
commit ef86d8c95c
8 changed files with 438 additions and 382 deletions
@@ -2321,7 +2321,109 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
]);
});
it("drops signed thinking turns when replay would expose inline sessions_spawn attachments", async () => {
it("keeps signed thinking turns that reuse a mutable earlier tool id", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_1",
content: [{ type: "text", text: "mutable result" }],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "internal", thinkingSignature: "sig_1" },
{ type: "toolUse", id: "call_1", name: "read", input: {} },
],
},
{
role: "toolResult",
toolCallId: "call_1",
content: [{ type: "text", text: "signed result" }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateAnthropicTurns: true,
preserveSignatures: true,
dropThinkingBlocks: false,
} as never);
const stream = wrapped(
{ api: "anthropic-messages" } as never,
{ messages } as never,
{} as never,
) as FakeWrappedStream | Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = firstBaseContext(baseFn);
expect(seenContext.messages).toBe(messages);
});
it("drops signed thinking reused ids when their real result is displaced", async () => {
const firstAssistant = {
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
};
const firstResult = {
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "mutable result" }],
};
const userMessage = {
role: "user",
content: [{ type: "text", text: "retry" }],
};
const messages = [
firstAssistant,
firstResult,
{
role: "assistant",
content: [
{ type: "thinking", thinking: "internal", thinkingSignature: "sig_1" },
{ type: "toolUse", id: "call_1", name: "read", input: {} },
],
},
userMessage,
{
role: "toolResult",
toolCallId: "call_1",
content: [{ type: "text", text: "signed result" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateAnthropicTurns: true,
preserveSignatures: true,
dropThinkingBlocks: false,
} as never);
const stream = wrapped(
{ api: "anthropic-messages" } as never,
{ messages } as never,
{} as never,
) as FakeWrappedStream | Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = firstBaseContext(baseFn);
expect(seenContext.messages).toEqual([firstAssistant, firstResult, userMessage]);
});
it("drops signed thinking turns with inline sessions_spawn attachments when the result is missing", async () => {
const attachmentContent = "SIGNED_THINKING_INLINE_ATTACHMENT";
const messages = [
{
@@ -2374,7 +2476,7 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
]);
});
it("drops signed thinking turns when replay would expose non-content attachment payload fields", async () => {
it("drops signed thinking turns with non-content attachment payload fields when the result is missing", async () => {
const attachmentContent = "SIGNED_THINKING_NESTED_ATTACHMENT";
const messages = [
{
@@ -2433,6 +2535,60 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
]);
});
it("keeps signed thinking turns with sessions_spawn attachments when the tool result is present", async () => {
const attachmentContent = "SIGNED_THINKING_PAIRED_ATTACHMENT";
const messages = [
{
role: "assistant",
content: [
{ type: "thinking", thinking: "internal", thinkingSignature: "sig_1" },
{
type: "toolUse",
id: "call_1",
name: "sessions_spawn",
input: {
task: "inspect attachment",
attachments: [{ name: "snapshot.txt", content: attachmentContent }],
},
},
],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "sessions_spawn",
content: [{ type: "text", text: "done" }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["sessions_spawn"]),
{
validateAnthropicTurns: true,
preserveSignatures: true,
dropThinkingBlocks: false,
} as never,
);
const stream = wrapped(
{ api: "anthropic-messages" } as never,
{ messages } as never,
{} as never,
) as FakeWrappedStream | Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = firstBaseContext(baseFn);
expect(seenContext.messages).toBe(messages);
});
it("keeps mutable thinking turns outside anthropic replay-only preservation", async () => {
const messages = [
{
@@ -3044,10 +3200,6 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "[tool calls omitted]" }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
@@ -7,10 +7,10 @@ import { validateAnthropicTurns, validateGeminiTurns } from "../../pi-embedded-h
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
import {
extractToolCallsFromAssistant,
extractToolResultIds,
sanitizeToolCallIdsForCloudCodeAssist,
type ToolCallIdMode,
} from "../../tool-call-id.js";
import { hasUnredactedSessionsSpawnAttachments } from "../../tool-call-shared.js";
import { normalizeToolName } from "../../tool-policy.js";
import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js";
import type { TranscriptPolicy } from "../../transcript-policy.js";
@@ -263,12 +263,7 @@ function isReplaySafeThinkingTurn(content: unknown[], allowedToolNames?: Set<str
}
const replayBlock = block;
const toolCallId = typeof replayBlock.id === "string" ? replayBlock.id.trim() : "";
if (
!replayToolCallHasInput(replayBlock) ||
!toolCallId ||
seenToolCallIds.has(toolCallId) ||
hasUnredactedSessionsSpawnAttachments(replayBlock)
) {
if (!replayToolCallHasInput(replayBlock) || !toolCallId || seenToolCallIds.has(toolCallId)) {
return false;
}
seenToolCallIds.add(toolCallId);
@@ -295,6 +290,35 @@ function replayToolCallHasInput(block: ReplayToolCallBlock): boolean {
return hasInput || hasArguments;
}
function collectFollowingToolResults(
messages: AgentMessage[],
index: number,
): { ids: Set<string>; displaced: boolean } {
const ids = new Set<string>();
let sawNonToolResult = false;
let displaced = false;
for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex += 1) {
const message = messages[nextIndex];
if (!message || typeof message !== "object") {
sawNonToolResult = true;
continue;
}
if (message.role === "assistant" && assistantTurnHasReplayToolCall(message)) {
break;
}
if (message.role === "toolResult") {
const resultIds = extractToolResultIds(message);
for (const id of resultIds) {
ids.add(id);
}
displaced ||= resultIds.length > 0 && sawNonToolResult;
continue;
}
sawNonToolResult = true;
}
return { ids, displaced };
}
function replayToolCallNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -326,9 +350,11 @@ function sanitizeReplayToolCallInputs(
let changed = false;
let droppedAssistantMessages = 0;
const out: AgentMessage[] = [];
const claimedReplaySafeToolCallIds = new Set<string>();
const preservedThinkingToolCallIds = new Set<string>();
const priorToolCallIds = new Set<string>();
for (const message of messages) {
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (!message || typeof message !== "object" || message.role !== "assistant") {
out.push(message);
continue;
@@ -343,13 +369,21 @@ function sanitizeReplayToolCallInputs(
message.content.some((block) => isReplayToolCallBlock(block))
) {
const replaySafeToolCalls = extractToolCallsFromAssistant(message);
const followingToolResults = collectFollowingToolResults(messages, index);
if (
isReplaySafeThinkingTurn(message.content, allowedToolNames) &&
replaySafeToolCalls.every((toolCall) => !claimedReplaySafeToolCallIds.has(toolCall.id))
replaySafeToolCalls.every(
(toolCall) =>
!preservedThinkingToolCallIds.has(toolCall.id) &&
(!followingToolResults.displaced || !priorToolCallIds.has(toolCall.id)) &&
followingToolResults.ids.has(toolCall.id),
)
) {
for (const toolCall of replaySafeToolCalls) {
claimedReplaySafeToolCallIds.add(toolCall.id);
preservedThinkingToolCallIds.add(toolCall.id);
priorToolCallIds.add(toolCall.id);
}
changed ||= followingToolResults.displaced;
out.push(message);
} else {
changed = true;
@@ -394,13 +428,20 @@ function sanitizeReplayToolCallInputs(
if (messageChanged) {
changed = true;
if (nextContent.length > 0) {
out.push({ ...message, content: nextContent });
const nextMessage = { ...message, content: nextContent };
for (const toolCall of extractToolCallsFromAssistant(nextMessage)) {
priorToolCallIds.add(toolCall.id);
}
out.push(nextMessage);
} else {
droppedAssistantMessages += 1;
}
continue;
}
for (const toolCall of extractToolCallsFromAssistant(message)) {
priorToolCallIds.add(toolCall.id);
}
out.push(message);
}
@@ -27,39 +27,18 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage {
});
}
describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
it("replaces attachments[].content with __OPENCLAW_REDACTED__", () => {
const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret
const input = [mkSessionsSpawnToolCall(secret)];
describe("sanitizeToolCallInputs preserves sessions_spawn payloads", () => {
it("keeps attachment content in transcript-owned tool calls", () => {
const content = "LOCAL_ATTACHMENT_CONTENT";
const input = [mkSessionsSpawnToolCall(content)];
const out = sanitizeToolCallInputs(input);
expect(out).toStrictEqual([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "sessions_spawn",
arguments: {
task: "do thing",
attachments: [
{
name: "README.md",
encoding: "utf8",
content: "__OPENCLAW_REDACTED__",
},
],
},
},
],
timestamp: 0,
},
]);
expect(JSON.stringify(out)).not.toContain(secret);
expect(out).toStrictEqual(input);
expect(JSON.stringify(out)).toContain(content);
});
it("redacts attachments content from tool input payloads too", () => {
const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret
it("keeps attachment content from tool input payloads too", () => {
const content = "INPUT_ATTACHMENT_CONTENT";
const input = castAgentMessages([
{
role: "assistant",
@@ -70,7 +49,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
name: "sessions_spawn",
input: {
task: "do thing",
attachments: [{ name: "x.txt", content: secret }],
attachments: [{ name: "x.txt", content }],
},
},
],
@@ -78,32 +57,12 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
]);
const out = sanitizeToolCallInputs(input);
expect(out).toStrictEqual([
{
role: "assistant",
content: [
{
type: "toolUse",
id: "call_2",
name: "sessions_spawn",
input: {
task: "do thing",
attachments: [
{
name: "x.txt",
content: "__OPENCLAW_REDACTED__",
},
],
},
},
],
},
]);
expect(JSON.stringify(out)).not.toContain(secret);
expect(out).toStrictEqual(input);
expect(JSON.stringify(out)).toContain(content);
});
it("replaces non-content attachment payload fields with a minimal redacted stub", () => {
const secret = "NESTED_ATTACHMENT_SECRET"; // pragma: allowlist secret
it("keeps non-content attachment payload fields unchanged", () => {
const nestedValue = "NESTED_ATTACHMENT_VALUE";
const input = castAgentMessages([
{
role: "assistant",
@@ -119,8 +78,8 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
name: "payload.json",
mimeType: "application/json",
encoding: "utf8",
data: secret,
nested: { secret },
data: nestedValue,
nested: { value: nestedValue },
},
],
},
@@ -130,26 +89,11 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
]);
const out = sanitizeToolCallInputs(input);
const msg = out[0] as { content?: unknown[] };
const tool = (msg.content?.[0] ?? null) as {
input?: { attachments?: unknown[] };
arguments?: { attachments?: unknown[] };
} | null;
const attachment = (tool?.input?.attachments?.[0] ??
tool?.arguments?.attachments?.[0] ??
null) as Record<string, unknown> | null;
expect(attachment).toEqual({
name: "payload.json",
mimeType: "application/json",
encoding: "utf8",
content: "__OPENCLAW_REDACTED__",
});
expect(JSON.stringify(out)).not.toContain(secret);
expect(out).toStrictEqual(input);
expect(JSON.stringify(out)).toContain(nestedValue);
});
it("redacts ACP-only routing fields from arguments and input payloads", () => {
const argumentResumeSessionId = "ACP_ARGUMENT_SESSION_ID_SHOULD_NOT_PERSIST"; // pragma: allowlist secret
const inputResumeSessionId = "ACP_INPUT_SESSION_ID_SHOULD_NOT_PERSIST"; // pragma: allowlist secret
it("keeps ACP routing fields unchanged", () => {
const input = castAgentMessages([
{
role: "assistant",
@@ -160,7 +104,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
name: "sessions_spawn",
arguments: {
task: "do thing",
resumeSessionId: argumentResumeSessionId,
resumeSessionId: "argument-session",
streamTo: "parent",
},
},
@@ -170,7 +114,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
name: "sessions_spawn",
input: {
task: "do other thing",
resumeSessionId: inputResumeSessionId,
resumeSessionId: "input-session",
streamTo: "parent",
},
},
@@ -179,75 +123,6 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
]);
const out = sanitizeToolCallInputs(input);
expect(out).toStrictEqual([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_4",
name: "sessions_spawn",
arguments: {
task: "do thing",
resumeSessionId: "__OPENCLAW_REDACTED__",
streamTo: "__OPENCLAW_REDACTED__",
},
},
{
type: "toolUse",
id: "call_5",
name: "sessions_spawn",
input: {
task: "do other thing",
resumeSessionId: "__OPENCLAW_REDACTED__",
streamTo: "__OPENCLAW_REDACTED__",
},
},
],
},
]);
expect(JSON.stringify(out)).not.toContain(argumentResumeSessionId);
expect(JSON.stringify(out)).not.toContain(inputResumeSessionId);
});
it("redacts ACP-only routing fields with non-string payloads", () => {
const nestedResumeSessionId = "ACP_NESTED_SESSION_ID_SHOULD_NOT_PERSIST"; // pragma: allowlist secret
const input = castAgentMessages([
{
role: "assistant",
content: [
{
type: "toolUse",
id: "call_6",
name: "sessions_spawn",
input: {
task: "do nested thing",
resumeSessionId: { value: nestedResumeSessionId },
streamTo: ["parent"],
},
},
],
},
]);
const out = sanitizeToolCallInputs(input);
expect(out).toStrictEqual([
{
role: "assistant",
content: [
{
type: "toolUse",
id: "call_6",
name: "sessions_spawn",
input: {
task: "do nested thing",
resumeSessionId: "__OPENCLAW_REDACTED__",
streamTo: "__OPENCLAW_REDACTED__",
},
},
],
},
]);
expect(JSON.stringify(out)).not.toContain(nestedResumeSessionId);
expect(out).toStrictEqual(input);
});
});
+103 -18
View File
@@ -595,11 +595,94 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
allowProviderOwnedThinkingReplay: true,
});
expect(out).toEqual([input[0]]);
expect(out).toEqual([]);
});
it("drops signed-thinking assistant turns that would require attachment redaction", () => {
const secret = "SIGNED_THINKING_ATTACHMENT_SECRET"; // pragma: allowlist secret
it("preserves signed-thinking turns that reuse a mutable earlier tool id", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_shared", name: "read", arguments: { path: "a" } }],
},
{
role: "toolResult",
toolCallId: "call_shared",
content: [{ type: "text", text: "mutable result" }],
},
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "Signed replay can keep its provider-owned id.",
thinkingSignature: "sig_later",
},
{ type: "toolUse", id: "call_shared", name: "read", input: { path: "b" } },
],
},
{
role: "toolResult",
toolCallId: "stale_call",
toolUseId: "call_shared",
content: [{ type: "text", text: "signed result" }],
},
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["read"],
allowProviderOwnedThinkingReplay: true,
});
expect(out).toBe(input);
});
it("drops signed-thinking reused ids when their real result is displaced", () => {
const firstAssistant = {
role: "assistant",
content: [{ type: "toolCall", id: "call_shared", name: "read", arguments: { path: "a" } }],
} as const;
const firstResult = {
role: "toolResult",
toolCallId: "call_shared",
content: [{ type: "text", text: "mutable result" }],
} as const;
const userMessage = {
role: "user",
content: [{ type: "text", text: "interstitial" }],
} as const;
const displacedResult = {
role: "toolResult",
toolCallId: "call_shared",
content: [{ type: "text", text: "signed result" }],
} as const;
const input = castAgentMessages([
firstAssistant,
firstResult,
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "Signed replay has a displaced result.",
thinkingSignature: "sig_later",
},
{ type: "toolUse", id: "call_shared", name: "read", input: { path: "b" } },
],
},
userMessage,
displacedResult,
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["read"],
allowProviderOwnedThinkingReplay: true,
});
expect(out).toEqual([firstAssistant, firstResult, userMessage, displacedResult]);
});
it("drops signed-thinking assistant turns with sessions_spawn attachments when the result is missing", () => {
const content = "SIGNED_THINKING_ATTACHMENT_CONTENT";
const input = castAgentMessages([
{
role: "assistant",
@@ -615,7 +698,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
name: "sessions_spawn",
input: {
task: "inspect attachment",
attachments: [{ name: "snapshot.txt", content: secret }],
attachments: [{ name: "snapshot.txt", content }],
},
},
],
@@ -627,19 +710,20 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
allowProviderOwnedThinkingReplay: true,
});
expect(out).toStrictEqual([]);
expect(JSON.stringify(out)).not.toContain(secret);
expect(out).toEqual([]);
expect(JSON.stringify(out)).not.toContain(content);
});
it("keeps signed-thinking assistant turns when sessions_spawn attachments are already redacted", () => {
it("keeps signed-thinking assistant turns with sessions_spawn attachments when the result is present", () => {
const content = "SIGNED_THINKING_ATTACHMENT_CONTENT";
const input = castAgentMessages([
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "Let me replay the helper turn.",
thinkingSignature: "sig_spawn_safe",
thinking: "Let me spawn a helper.",
thinkingSignature: "sig_spawn",
},
{
type: "toolUse",
@@ -647,17 +731,17 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
name: "sessions_spawn",
input: {
task: "inspect attachment",
attachments: [
{
name: "snapshot.txt",
mimeType: "text/plain",
content: "__OPENCLAW_REDACTED__",
},
],
attachments: [{ name: "snapshot.txt", content }],
},
},
],
},
{
role: "toolResult",
toolCallId: "call_spawn",
toolName: "sessions_spawn",
content: [{ type: "text", text: "done" }],
},
]);
const out = sanitizeToolCallInputs(input, {
@@ -666,6 +750,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
});
expect(out).toEqual(input);
expect(JSON.stringify(out)).toContain(content);
});
it("keeps generic thinking turns mutable when immutable preservation is disabled", () => {
@@ -768,7 +853,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
expect((toolCalls[0] ?? {}).input).toEqual({ task: "hello" });
});
it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => {
it("preserves sessions_spawn attachments for mixed-case and padded tool names", () => {
const input = castAgentMessages([
{
role: "assistant",
@@ -793,7 +878,7 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
expect((toolCalls[0] ?? {}).name).toBe("SESSIONS_SPAWN");
const inputObj = (toolCalls[0]?.input ?? {}) as Record<string, unknown>;
const attachments = (inputObj.attachments ?? []) as Array<Record<string, unknown>>;
expect(attachments[0]?.content).toBe("__OPENCLAW_REDACTED__");
expect(attachments[0]?.content).toBe("SECRET");
});
it("preserves other block properties when trimming tool names", () => {
const toolCalls = sanitizeAssistantToolCalls([
+71 -126
View File
@@ -1,18 +1,15 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
hasNonEmptyString as hasNonEmptyStringField,
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
readStringValue,
} from "../shared/string-coerce.js";
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
import {
REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT,
SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS,
isAllowedToolCallName,
isRedactedSessionsSpawnAttachment,
normalizeAllowedToolNames,
} from "./tool-call-shared.js";
extractToolCallsFromAssistant,
extractToolResultId,
extractToolResultIds,
} from "./tool-call-id.js";
import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js";
type RawToolCallBlock = {
type?: unknown;
@@ -70,102 +67,23 @@ function hasToolCallId(block: RawToolCallBlock): boolean {
);
}
function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown {
if (!value || typeof value !== "object") {
return value;
}
const rec = value as Record<string, unknown>;
const raw = rec.attachments;
if (!Array.isArray(raw)) {
return value;
}
let changed = false;
const next = raw.map((item) => {
if (isRedactedSessionsSpawnAttachment(item)) {
return item;
}
changed = true;
return redactSessionsSpawnAttachment(item);
});
if (!changed) {
return value;
}
return { ...rec, attachments: next };
}
function redactSessionsSpawnAcpArgs(value: unknown): unknown {
if (!value || typeof value !== "object") {
return value;
}
const rec = value as Record<string, unknown>;
const next = { ...rec };
let changed = false;
for (const key of ["resumeSessionId", "streamTo"] as const) {
if (Object.hasOwn(rec, key)) {
next[key] = REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT;
changed = true;
}
}
return changed ? next : value;
}
function redactSessionsSpawnArgs(value: unknown): unknown {
return redactSessionsSpawnAcpArgs(redactSessionsSpawnAttachmentsArgs(value));
}
function redactSessionsSpawnAttachment(item: unknown): Record<string, unknown> {
const next: Record<string, unknown> = {
content: REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT,
};
if (!item || typeof item !== "object") {
return next;
}
const attachment = item as Record<string, unknown>;
for (const key of SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS) {
const value = attachment[key];
if (typeof value === "string" && value.trim().length > 0) {
next[key] = value;
}
}
return next;
}
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
// This repair path normalizes replay shape only. Tool payloads are local
// trusted-operator transcript state per SECURITY.md, so do not redact or
// rewrite sessions_spawn arguments here.
const rawName = readStringValue(block.name);
const trimmedName = rawName?.trim();
const hasTrimmedName = typeof trimmedName === "string" && trimmedName.length > 0;
const normalizedName = hasTrimmedName ? trimmedName : undefined;
const nameChanged = hasTrimmedName && rawName !== trimmedName;
const isSessionsSpawn = normalizeLowercaseStringOrEmpty(normalizedName) === "sessions_spawn";
if (!isSessionsSpawn) {
if (!nameChanged) {
return block;
}
return { ...(block as Record<string, unknown>), name: normalizedName } as RawToolCallBlock;
}
// Redact sensitive sessions_spawn payload fields from persisted transcripts.
// Apply redaction to both `.arguments` and `.input` properties since block structures can vary.
const nextArgs = redactSessionsSpawnArgs(block.arguments);
const nextInput = redactSessionsSpawnArgs(block.input);
if (nextArgs === block.arguments && nextInput === block.input && !nameChanged) {
if (!nameChanged) {
return block;
}
const next = { ...(block as Record<string, unknown>) };
if (nameChanged && normalizedName) {
next.name = normalizedName;
}
if (nextArgs !== block.arguments || Object.hasOwn(block, "arguments")) {
next.arguments = nextArgs;
}
if (nextInput !== block.input || Object.hasOwn(block, "input")) {
next.input = nextInput;
}
return next as RawToolCallBlock;
}
@@ -298,6 +216,35 @@ export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[]
return touched ? out : messages;
}
function collectFollowingToolResults(
messages: AgentMessage[],
index: number,
): { ids: Set<string>; displaced: boolean } {
const ids = new Set<string>();
let sawNonToolResult = false;
let displaced = false;
for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex += 1) {
const message = messages[nextIndex];
if (!message || typeof message !== "object") {
sawNonToolResult = true;
continue;
}
if (message.role === "assistant" && assistantHasToolCalls(message)) {
break;
}
if (message.role === "toolResult") {
const resultIds = extractToolResultIds(message);
for (const id of resultIds) {
ids.add(id);
}
displaced ||= resultIds.length > 0 && sawNonToolResult;
continue;
}
sawNonToolResult = true;
}
return { ids, displaced };
}
function repairToolCallInputs(
messages: AgentMessage[],
options?: ToolCallInputRepairOptions,
@@ -308,9 +255,11 @@ function repairToolCallInputs(
const out: AgentMessage[] = [];
const allowedToolNames = normalizeAllowedToolNames(options?.allowedToolNames);
const allowProviderOwnedThinkingReplay = options?.allowProviderOwnedThinkingReplay === true;
const claimedReplaySafeToolCallIds = new Set<string>();
const preservedThinkingToolCallIds = new Set<string>();
const priorToolCallIds = new Set<string>();
for (const msg of messages) {
for (let index = 0; index < messages.length; index += 1) {
const msg = messages[index];
if (!msg || typeof msg !== "object") {
out.push(msg);
continue;
@@ -328,16 +277,24 @@ function repairToolCallInputs(
) {
// Signed Anthropic thinking blocks must remain byte-for-byte stable on
// replay. Preserve the turn only if every sibling tool call is already
// valid and requires no redaction or normalization. Otherwise drop the
// valid and already has a real tool result. Otherwise drop the
// whole assistant turn rather than mutating provider-owned content.
const replaySafeToolCalls = extractToolCallsFromAssistant(msg);
const followingToolResults = collectFollowingToolResults(messages, index);
if (
isReplaySafeThinkingAssistantTurn(msg.content, allowedToolNames) &&
replaySafeToolCalls.every((toolCall) => !claimedReplaySafeToolCallIds.has(toolCall.id))
replaySafeToolCalls.every(
(toolCall) =>
!preservedThinkingToolCallIds.has(toolCall.id) &&
(!followingToolResults.displaced || !priorToolCallIds.has(toolCall.id)) &&
followingToolResults.ids.has(toolCall.id),
)
) {
for (const toolCall of replaySafeToolCalls) {
claimedReplaySafeToolCallIds.add(toolCall.id);
preservedThinkingToolCallIds.add(toolCall.id);
priorToolCallIds.add(toolCall.id);
}
changed ||= followingToolResults.displaced;
out.push(msg);
} else {
droppedToolCalls += countRawToolCallBlocks(msg.content);
@@ -366,35 +323,12 @@ function repairToolCallInputs(
}
if (isRawToolCallBlock(block)) {
if (RAW_TOOL_CALL_BLOCK_TYPES.has((block as { type?: string }).type ?? "")) {
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
const blockName =
typeof (block as { name?: unknown }).name === "string"
? (block as { name: string }).name.trim()
: undefined;
if (normalizeLowercaseStringOrEmpty(blockName) === "sessions_spawn") {
const sanitized = sanitizeToolCallBlock(block);
if (sanitized !== block) {
changed = true;
messageChanged = true;
}
nextContent.push(sanitized as typeof block);
} else {
if (typeof (block as { name?: unknown }).name === "string") {
const rawName = (block as { name: string }).name;
const trimmedName = rawName.trim();
if (rawName !== trimmedName && trimmedName) {
const renamed = { ...(block as object), name: trimmedName } as typeof block;
nextContent.push(renamed);
changed = true;
messageChanged = true;
} else {
nextContent.push(block);
}
} else {
nextContent.push(block);
}
const sanitized = sanitizeToolCallBlock(block);
if (sanitized !== block) {
changed = true;
messageChanged = true;
}
nextContent.push(sanitized as typeof block);
continue;
}
} else {
@@ -408,15 +342,26 @@ function repairToolCallInputs(
changed = true;
continue;
}
out.push({ ...msg, content: nextContent });
const nextMessage = { ...msg, content: nextContent };
for (const toolCall of extractToolCallsFromAssistant(nextMessage)) {
priorToolCallIds.add(toolCall.id);
}
out.push(nextMessage);
continue;
}
if (messageChanged) {
out.push({ ...msg, content: nextContent });
const nextMessage = { ...msg, content: nextContent };
for (const toolCall of extractToolCallsFromAssistant(nextMessage)) {
priorToolCallIds.add(toolCall.id);
}
out.push(nextMessage);
continue;
}
for (const toolCall of extractToolCallsFromAssistant(msg)) {
priorToolCallIds.add(toolCall.id);
}
out.push(msg);
}
+31 -17
View File
@@ -1,10 +1,6 @@
import { createHash } from "node:crypto";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
hasUnredactedSessionsSpawnAttachments,
isAllowedToolCallName,
normalizeAllowedToolNames,
} from "./tool-call-shared.js";
import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js";
export type ToolCallIdMode = "strict" | "strict9";
const NATIVE_ANTHROPIC_TOOL_USE_ID_RE = /^toolu_[A-Za-z0-9_]+$/;
@@ -90,15 +86,36 @@ export function extractToolCallsFromAssistant(
export function extractToolResultId(
msg: Extract<AgentMessage, { role: "toolResult" }>,
): string | null {
const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
if (typeof toolCallId === "string" && toolCallId) {
return toolCallId;
return extractToolResultIds(msg)[0] ?? null;
}
export function extractToolResultIds(msg: Extract<AgentMessage, { role: "toolResult" }>): string[] {
const ids: string[] = [];
const record = msg as {
toolCallId?: unknown;
toolUseId?: unknown;
tool_call_id?: unknown;
tool_use_id?: unknown;
callId?: unknown;
call_id?: unknown;
};
for (const value of [
record.toolCallId,
record.toolUseId,
record.tool_call_id,
record.tool_use_id,
record.callId,
record.call_id,
]) {
if (typeof value !== "string") {
continue;
}
const id = value.trim();
if (id && !ids.includes(id)) {
ids.push(id);
}
}
const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
if (typeof toolUseId === "string" && toolUseId) {
return toolUseId;
}
return null;
return ids;
}
function isThinkingLikeBlock(block: unknown): boolean {
@@ -119,10 +136,7 @@ function hasToolCallInput(block: ReplaySafeToolCallBlock): boolean {
function toolCallNeedsReplayMutation(block: ReplaySafeToolCallBlock): boolean {
const rawName = typeof block.name === "string" ? block.name : undefined;
const trimmedName = rawName?.trim();
if (rawName && rawName !== trimmedName) {
return true;
}
return hasUnredactedSessionsSpawnAttachments(block);
return !!rawName && rawName !== trimmedName;
}
function isReplaySafeThinkingAssistantMessage(
-55
View File
@@ -3,9 +3,6 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const TOOL_CALL_NAME_MAX_CHARS = 64;
const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_:.-]+$/;
export const REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT = "__OPENCLAW_REDACTED__";
export const SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS = ["name", "encoding", "mimeType"] as const;
export function normalizeAllowedToolNames(allowedToolNames?: Iterable<string>): Set<string> | null {
if (!allowedToolNames) {
return null;
@@ -43,55 +40,3 @@ export function isAllowedToolCallName(
}
return allowedToolNames.has(normalizeLowercaseStringOrEmpty(trimmed));
}
export function isRedactedSessionsSpawnAttachment(item: unknown): boolean {
if (!item || typeof item !== "object") {
return false;
}
const attachment = item as Record<string, unknown>;
if (attachment.content !== REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT) {
return false;
}
for (const key of Object.keys(attachment)) {
if (key === "content") {
continue;
}
if (!(SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS as readonly string[]).includes(key)) {
return false;
}
if (typeof attachment[key] !== "string" || attachment[key].trim().length === 0) {
return false;
}
}
return true;
}
type SessionsSpawnAttachmentToolCallBlock = {
name?: unknown;
input?: unknown;
arguments?: unknown;
};
export function hasUnredactedSessionsSpawnAttachments(
block: SessionsSpawnAttachmentToolCallBlock,
): boolean {
const rawName = typeof block.name === "string" ? block.name.trim() : "";
if (normalizeLowercaseStringOrEmpty(rawName) !== "sessions_spawn") {
return false;
}
for (const payload of [block.arguments, block.input]) {
if (!payload || typeof payload !== "object") {
continue;
}
const attachments = (payload as { attachments?: unknown }).attachments;
if (!Array.isArray(attachments)) {
continue;
}
for (const attachment of attachments) {
if (!isRedactedSessionsSpawnAttachment(attachment)) {
return true;
}
}
}
return false;
}
-1
View File
@@ -196,7 +196,6 @@ function createSessionsSpawnToolSchema(params: {
),
// Inline attachments (snapshot-by-value).
// NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs.
attachments: Type.Optional(
Type.Array(
Type.Object({