mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 05:24: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:
@@ -53,7 +53,6 @@ describe("sessions_spawn tool", () => {
|
||||
thread: true,
|
||||
mode: "session",
|
||||
cleanup: "keep",
|
||||
sandbox: "require",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
@@ -71,7 +70,6 @@ describe("sessions_spawn tool", () => {
|
||||
thread: true,
|
||||
mode: "session",
|
||||
cleanup: "keep",
|
||||
sandbox: "require",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentSessionKey: "agent:main:main",
|
||||
@@ -80,25 +78,6 @@ describe("sessions_spawn tool", () => {
|
||||
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults sandbox to "inherit" for subagent runtime', async () => {
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
});
|
||||
|
||||
await tool.execute("call-sandbox-default", {
|
||||
task: "summarize logs",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandbox: "inherit",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to ACP runtime when runtime=acp", async () => {
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
@@ -137,25 +116,27 @@ describe("sessions_spawn tool", () => {
|
||||
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["target", "transport", "channel", "to", "threadId", "thread_id", "replyTo", "reply_to"])(
|
||||
"rejects unsupported routing parameter %s",
|
||||
async (key) => {
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
agentTo: "channel:123",
|
||||
agentThreadId: "456",
|
||||
});
|
||||
it("rejects attachments for ACP runtime", async () => {
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
agentTo: "channel:123",
|
||||
agentThreadId: "456",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-unsupported-param", {
|
||||
task: "build feature",
|
||||
[key]: "value",
|
||||
}),
|
||||
).rejects.toThrow(`sessions_spawn does not support "${key}"`);
|
||||
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
const result = await tool.execute("call-3", {
|
||||
runtime: "acp",
|
||||
task: "analyze file",
|
||||
attachments: [{ name: "a.txt", content: "hello", encoding: "utf8" }],
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
});
|
||||
const details = result.details as { error?: string };
|
||||
expect(details.error).toContain("attachments are currently unsupported for runtime=acp");
|
||||
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,27 @@ const SessionsSpawnToolSchema = Type.Object({
|
||||
mode: optionalStringEnum(SUBAGENT_SPAWN_MODES),
|
||||
cleanup: optionalStringEnum(["delete", "keep"] as const),
|
||||
sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES),
|
||||
|
||||
// Inline attachments (snapshot-by-value).
|
||||
// NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs.
|
||||
attachments: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
name: Type.String(),
|
||||
content: Type.String({ maxLength: 6_700_000 }),
|
||||
encoding: Type.Optional(optionalStringEnum(["utf8", "base64"] as const)),
|
||||
mimeType: Type.Optional(Type.String()),
|
||||
}),
|
||||
{ maxItems: 50 },
|
||||
),
|
||||
),
|
||||
attachAs: Type.Optional(
|
||||
Type.Object({
|
||||
// Where the spawned agent should look for attachments.
|
||||
// Kept as a hint; implementation materializes into the child workspace.
|
||||
mountPath: Type.Optional(Type.String()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export function createSessionsSpawnTool(opts?: {
|
||||
@@ -88,52 +109,74 @@ export function createSessionsSpawnTool(opts?: {
|
||||
? Math.max(0, Math.floor(timeoutSecondsCandidate))
|
||||
: undefined;
|
||||
const thread = params.thread === true;
|
||||
const attachments = Array.isArray(params.attachments)
|
||||
? (params.attachments as Array<{
|
||||
name: string;
|
||||
content: string;
|
||||
encoding?: "utf8" | "base64";
|
||||
mimeType?: string;
|
||||
}>)
|
||||
: undefined;
|
||||
|
||||
const result =
|
||||
runtime === "acp"
|
||||
? await spawnAcpDirect(
|
||||
{
|
||||
task,
|
||||
label: label || undefined,
|
||||
agentId: requestedAgentId,
|
||||
cwd,
|
||||
mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined,
|
||||
thread,
|
||||
},
|
||||
{
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
agentChannel: opts?.agentChannel,
|
||||
agentAccountId: opts?.agentAccountId,
|
||||
agentTo: opts?.agentTo,
|
||||
agentThreadId: opts?.agentThreadId,
|
||||
},
|
||||
)
|
||||
: await spawnSubagentDirect(
|
||||
{
|
||||
task,
|
||||
label: label || undefined,
|
||||
agentId: requestedAgentId,
|
||||
model: modelOverride,
|
||||
thinking: thinkingOverrideRaw,
|
||||
runTimeoutSeconds,
|
||||
thread,
|
||||
mode,
|
||||
cleanup,
|
||||
sandbox,
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
agentChannel: opts?.agentChannel,
|
||||
agentAccountId: opts?.agentAccountId,
|
||||
agentTo: opts?.agentTo,
|
||||
agentThreadId: opts?.agentThreadId,
|
||||
agentGroupId: opts?.agentGroupId,
|
||||
agentGroupChannel: opts?.agentGroupChannel,
|
||||
agentGroupSpace: opts?.agentGroupSpace,
|
||||
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
|
||||
},
|
||||
);
|
||||
if (runtime === "acp") {
|
||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
error:
|
||||
"attachments are currently unsupported for runtime=acp; use runtime=subagent or remove attachments",
|
||||
});
|
||||
}
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task,
|
||||
label: label || undefined,
|
||||
agentId: requestedAgentId,
|
||||
cwd,
|
||||
mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined,
|
||||
thread,
|
||||
},
|
||||
{
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
agentChannel: opts?.agentChannel,
|
||||
agentAccountId: opts?.agentAccountId,
|
||||
agentTo: opts?.agentTo,
|
||||
agentThreadId: opts?.agentThreadId,
|
||||
},
|
||||
);
|
||||
return jsonResult(result);
|
||||
}
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task,
|
||||
label: label || undefined,
|
||||
agentId: requestedAgentId,
|
||||
model: modelOverride,
|
||||
thinking: thinkingOverrideRaw,
|
||||
runTimeoutSeconds,
|
||||
thread,
|
||||
mode,
|
||||
cleanup,
|
||||
sandbox,
|
||||
expectsCompletionMessage: true,
|
||||
attachments,
|
||||
attachMountPath:
|
||||
params.attachAs && typeof params.attachAs === "object"
|
||||
? readStringParam(params.attachAs as Record<string, unknown>, "mountPath")
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
agentChannel: opts?.agentChannel,
|
||||
agentAccountId: opts?.agentAccountId,
|
||||
agentTo: opts?.agentTo,
|
||||
agentThreadId: opts?.agentThreadId,
|
||||
agentGroupId: opts?.agentGroupId,
|
||||
agentGroupChannel: opts?.agentGroupChannel,
|
||||
agentGroupSpace: opts?.agentGroupSpace,
|
||||
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
|
||||
},
|
||||
);
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user