mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 17:04:58 +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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user