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:
Nikolay Petrov
2026-03-01 21:33:51 -08:00
committed by GitHub
parent 842deefe5d
commit a9f1188785
15 changed files with 1039 additions and 135 deletions

View File

@@ -1,3 +1,5 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
@@ -561,6 +563,8 @@ async function sweepSubagentRuns() {
clearPendingLifecycleError(runId);
subagentRuns.delete(runId);
mutated = true;
// Archive/purge is terminal for the run record; remove any retained attachments too.
await safeRemoveAttachmentsDir(entry);
try {
await callGateway({
method: "sessions.delete",
@@ -637,6 +641,44 @@ function ensureListener() {
});
}
async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promise<void> {
if (!entry.attachmentsDir || !entry.attachmentsRootDir) {
return;
}
const resolveReal = async (targetPath: string): Promise<string | null> => {
try {
return await fs.realpath(targetPath);
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
return null;
}
throw err;
}
};
try {
const [rootReal, dirReal] = await Promise.all([
resolveReal(entry.attachmentsRootDir),
resolveReal(entry.attachmentsDir),
]);
if (!dirReal) {
return;
}
const rootBase = rootReal ?? path.resolve(entry.attachmentsRootDir);
// dirReal is guaranteed non-null here (early return above handles null case).
const dirBase = dirReal;
const rootWithSep = rootBase.endsWith(path.sep) ? rootBase : `${rootBase}${path.sep}`;
if (!dirBase.startsWith(rootWithSep)) {
return;
}
await fs.rm(dirBase, { recursive: true, force: true });
} catch {
// best effort
}
}
async function finalizeSubagentCleanup(
runId: string,
cleanup: "delete" | "keep",
@@ -649,6 +691,11 @@ async function finalizeSubagentCleanup(
if (didAnnounce) {
const completionReason = resolveCleanupCompletionReason(entry);
await emitCompletionEndedHookIfNeeded(entry, completionReason);
// Clean up attachments before the run record is removed.
const shouldDeleteAttachments = cleanup === "delete" || !entry.retainAttachmentsOnKeep;
if (shouldDeleteAttachments) {
await safeRemoveAttachmentsDir(entry);
}
completeCleanupBookkeeping({
runId,
entry,
@@ -686,6 +733,10 @@ async function finalizeSubagentCleanup(
}
if (deferredDecision.kind === "give-up") {
const shouldDeleteAttachments = cleanup === "delete" || !entry.retainAttachmentsOnKeep;
if (shouldDeleteAttachments) {
await safeRemoveAttachmentsDir(entry);
}
const completionReason = resolveCleanupCompletionReason(entry);
await emitCompletionEndedHookIfNeeded(entry, completionReason);
logAnnounceGiveUp(entry, deferredDecision.reason);
@@ -699,6 +750,8 @@ async function finalizeSubagentCleanup(
}
// Allow retry on the next wake if announce was deferred or failed.
// Applies to both keep/delete cleanup modes so delete-runs are only removed
// after a successful announce (or terminal give-up).
entry.cleanupHandled = false;
resumedRuns.delete(runId);
persistSubagentRuns();
@@ -905,6 +958,9 @@ export function registerSubagentRun(params: {
runTimeoutSeconds?: number;
expectsCompletionMessage?: boolean;
spawnMode?: "run" | "session";
attachmentsDir?: string;
attachmentsRootDir?: string;
retainAttachmentsOnKeep?: boolean;
}) {
const now = Date.now();
const cfg = loadConfig();
@@ -932,6 +988,9 @@ export function registerSubagentRun(params: {
startedAt: now,
archiveAtMs,
cleanupHandled: false,
attachmentsDir: params.attachmentsDir,
attachmentsRootDir: params.attachmentsRootDir,
retainAttachmentsOnKeep: params.retainAttachmentsOnKeep,
});
ensureListener();
persistSubagentRuns();