mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 21:44:32 +00:00
sessions_spawn: inline attachments with redaction, lifecycle cleanup, and docs (#16761)
Add inline file attachment support for sessions_spawn (subagent runtime only): - Schema: attachments[] (name, content, encoding, mimeType) and attachAs.mountPath hint - Materialization: files written to .openclaw/attachments/<uuid>/ with manifest.json - Validation: strict base64 decode, filename checks, size limits, duplicate detection - Transcript redaction: sanitizeToolCallInputs redacts attachment content from persisted transcripts - Lifecycle cleanup: safeRemoveAttachmentsDir with symlink-safe path containment check - Config: tools.sessions_spawn.attachments (enabled, maxFiles, maxFileBytes, maxTotalBytes, retainOnSessionKeep) - Registry: attachmentsDir/attachmentsRootDir/retainAttachmentsOnKeep on SubagentRunRecord - ACP rejection: attachments rejected for runtime=acp with clear error message - Docs: updated tools/index.md, concepts/session-tool.md, configuration-reference.md - Tests: 85 new/updated tests across 5 test files Fixes: - Guard fs.rm in materialization catch block with try/catch (review concern #1) - Remove unreachable fallback in safeRemoveAttachmentsDir (review concern #7) - Move attachment cleanup out of retry path to avoid timing issues with announce loop Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM> Co-authored-by: napetrov <napetrov@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
sanitizeToolCallInputs,
|
||||
sanitizeToolUseResultPairing,
|
||||
repairToolUseResultPairing,
|
||||
stripToolResultDetails,
|
||||
} from "./session-transcript-repair.js";
|
||||
|
||||
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
|
||||
@@ -405,6 +406,57 @@ describe("sanitizeToolCallInputs", () => {
|
||||
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
|
||||
});
|
||||
|
||||
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolUse",
|
||||
id: "call_1",
|
||||
name: "sessions_spawn",
|
||||
input: { task: "hello" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(Object.hasOwn(toolCalls[0] ?? {}, "input")).toBe(true);
|
||||
expect(Object.hasOwn(toolCalls[0] ?? {}, "arguments")).toBe(false);
|
||||
expect((toolCalls[0] ?? {}).input).toEqual({ task: "hello" });
|
||||
});
|
||||
|
||||
it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolUse",
|
||||
id: "call_1",
|
||||
name: " SESSIONS_SPAWN ",
|
||||
input: {
|
||||
task: "hello",
|
||||
attachments: [{ name: "a.txt", content: "SECRET" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
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__");
|
||||
});
|
||||
it("preserves other block properties when trimming tool names", () => {
|
||||
const input = [
|
||||
{
|
||||
@@ -424,3 +476,45 @@ describe("sanitizeToolCallInputs", () => {
|
||||
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({ path: "/tmp/test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripToolResultDetails", () => {
|
||||
it("removes details only from toolResult messages", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: { internal: true },
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "keep me" }], details: { no: "touch" } },
|
||||
{ role: "user", content: "hello" },
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = stripToolResultDetails(input) as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
expect(Object.hasOwn(out[0] ?? {}, "details")).toBe(false);
|
||||
expect((out[0] ?? {}).role).toBe("toolResult");
|
||||
|
||||
// Non-toolResult messages are preserved as-is.
|
||||
expect(Object.hasOwn(out[1] ?? {}, "details")).toBe(true);
|
||||
expect((out[1] ?? {}).role).toBe("assistant");
|
||||
expect((out[2] ?? {}).role).toBe("user");
|
||||
});
|
||||
|
||||
it("returns the same array reference when there are no toolResult details", () => {
|
||||
const input = [
|
||||
{ role: "assistant", content: [{ type: "text", text: "a" }] },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
{ role: "user", content: "b" },
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const out = stripToolResultDetails(input);
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user