mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user