Skip to content

Commit a9f1188

Browse files
napetrovtyler6204
andauthored
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 <[email protected]> Co-authored-by: napetrov <[email protected]>
1 parent 842deef commit a9f1188

15 files changed

Lines changed: 1039 additions & 135 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
910
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
1011
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
1112
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.

docs/concepts/session-tool.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ Parameters:
157157
- `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`)
158158
- `cleanup?` (`delete|keep`, default `keep`)
159159
- `sandbox?` (`inherit|require`, default `inherit`; `require` rejects spawn unless the target child runtime is sandboxed)
160+
- `attachments?` (optional array of inline files; subagent runtime only, ACP rejects). Each entry: `{ name, content, encoding?: "utf8" | "base64", mimeType? }`. Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/`. Returns a receipt with sha256 per file.
161+
- `attachAs?` (optional; `{ mountPath? }` hint reserved for future mount implementations)
160162

161163
Allowlist:
162164

docs/gateway/configuration-reference.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1816,6 +1816,35 @@ Notes:
18161816
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
18171817
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
18181818

1819+
### `tools.sessions_spawn`
1820+
1821+
Controls inline attachment support for `sessions_spawn`.
1822+
1823+
```json5
1824+
{
1825+
tools: {
1826+
sessions_spawn: {
1827+
attachments: {
1828+
enabled: false, // opt-in: set true to allow inline file attachments
1829+
maxTotalBytes: 5242880, // 5 MB total across all files
1830+
maxFiles: 50,
1831+
maxFileBytes: 1048576, // 1 MB per file
1832+
retainOnSessionKeep: false, // keep attachments when cleanup="keep"
1833+
},
1834+
},
1835+
},
1836+
}
1837+
```
1838+
1839+
Notes:
1840+
1841+
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
1842+
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
1843+
- Attachment content is automatically redacted from transcript persistence.
1844+
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
1845+
- File permissions are `0700` for directories and `0600` for files.
1846+
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
1847+
18191848
### `tools.subagents`
18201849

18211850
```json5

docs/tools/index.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ Core parameters:
466466
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
467467
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
468468
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
469-
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`
469+
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `attachments?`, `attachAs?`
470470
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
471471

472472
Notes:
@@ -486,6 +486,9 @@ Notes:
486486
- Reply format includes `Status`, `Result`, and compact stats.
487487
- `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback.
488488
- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered).
489+
- `sessions_spawn` supports inline file attachments for subagent runtime only (ACP rejects them). Each attachment has `name`, `content`, and optional `encoding` (`utf8` or `base64`) and `mimeType`. Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json` metadata file. The tool returns a receipt with `count`, `totalBytes`, per file `sha256`, and `relDir`. Attachment content is automatically redacted from transcript persistence.
490+
- Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`).
491+
- `attachAs.mountPath` is a reserved hint for future mount implementations.
489492
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
490493
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
491494
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.

src/agents/pi-embedded-runner-extraparams.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ describe("applyExtraParamsToAgent", () => {
922922
provider: "openai",
923923
id: "gpt-5",
924924
baseUrl: "https://api.openai.com/v1",
925-
} as Model<"openai-responses">,
925+
} as unknown as Model<"openai-responses">,
926926
});
927927
expect(payload.store).toBe(true);
928928
});
@@ -936,7 +936,7 @@ describe("applyExtraParamsToAgent", () => {
936936
provider: "openai",
937937
id: "gpt-5",
938938
baseUrl: "https://proxy.example.com/v1",
939-
} as Model<"openai-responses">,
939+
} as unknown as Model<"openai-responses">,
940940
});
941941
expect(payload.store).toBe(false);
942942
});
@@ -950,7 +950,7 @@ describe("applyExtraParamsToAgent", () => {
950950
provider: "openai",
951951
id: "gpt-5",
952952
baseUrl: "",
953-
} as Model<"openai-responses">,
953+
} as unknown as Model<"openai-responses">,
954954
});
955955
expect(payload.store).toBe(false);
956956
});
@@ -971,7 +971,7 @@ describe("applyExtraParamsToAgent", () => {
971971
contextWindow: 128_000,
972972
maxTokens: 16_384,
973973
compat: { supportsStore: false },
974-
} as Model<"openai-responses"> & { compat?: { supportsStore?: boolean } },
974+
} as unknown as Model<"openai-responses">,
975975
});
976976
expect(payload.store).toBe(false);
977977
});
@@ -986,7 +986,7 @@ describe("applyExtraParamsToAgent", () => {
986986
id: "gpt-5",
987987
baseUrl: "https://api.openai.com/v1",
988988
contextWindow: 200_000,
989-
} as Model<"openai-responses">,
989+
} as unknown as Model<"openai-responses">,
990990
});
991991
expect(payload.context_management).toEqual([
992992
{
@@ -1005,7 +1005,7 @@ describe("applyExtraParamsToAgent", () => {
10051005
provider: "azure-openai-responses",
10061006
id: "gpt-4o",
10071007
baseUrl: "https://example.openai.azure.com/openai/v1",
1008-
} as Model<"openai-responses">,
1008+
} as unknown as Model<"openai-responses">,
10091009
});
10101010
expect(payload).not.toHaveProperty("context_management");
10111011
});
@@ -1033,7 +1033,7 @@ describe("applyExtraParamsToAgent", () => {
10331033
provider: "azure-openai-responses",
10341034
id: "gpt-4o",
10351035
baseUrl: "https://example.openai.azure.com/openai/v1",
1036-
} as Model<"openai-responses">,
1036+
} as unknown as Model<"openai-responses">,
10371037
});
10381038
expect(payload.context_management).toEqual([
10391039
{
@@ -1052,7 +1052,7 @@ describe("applyExtraParamsToAgent", () => {
10521052
provider: "openai",
10531053
id: "gpt-5",
10541054
baseUrl: "https://api.openai.com/v1",
1055-
} as Model<"openai-responses">,
1055+
} as unknown as Model<"openai-responses">,
10561056
payload: {
10571057
store: false,
10581058
context_management: [{ type: "compaction", compact_threshold: 12_345 }],
@@ -1083,7 +1083,7 @@ describe("applyExtraParamsToAgent", () => {
10831083
provider: "openai",
10841084
id: "gpt-5",
10851085
baseUrl: "https://api.openai.com/v1",
1086-
} as Model<"openai-responses">,
1086+
} as unknown as Model<"openai-responses">,
10871087
});
10881088
expect(payload).not.toHaveProperty("context_management");
10891089
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
2+
import { describe, it, expect } from "vitest";
3+
import { sanitizeToolCallInputs } from "./session-transcript-repair.js";
4+
5+
function mkSessionsSpawnToolCall(content: string): AgentMessage {
6+
return {
7+
role: "assistant",
8+
content: [
9+
{
10+
type: "toolCall",
11+
id: "call_1",
12+
name: "sessions_spawn",
13+
arguments: {
14+
task: "do thing",
15+
attachments: [
16+
{
17+
name: "README.md",
18+
encoding: "utf8",
19+
content,
20+
},
21+
],
22+
},
23+
},
24+
],
25+
timestamp: Date.now(),
26+
} as unknown as AgentMessage;
27+
}
28+
29+
describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
30+
it("replaces attachments[].content with __OPENCLAW_REDACTED__", () => {
31+
const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST";
32+
const input = [mkSessionsSpawnToolCall(secret)];
33+
const out = sanitizeToolCallInputs(input);
34+
expect(out).toHaveLength(1);
35+
const msg = out[0] as { content?: unknown[] };
36+
const tool = (msg.content?.[0] ?? null) as {
37+
name?: string;
38+
arguments?: { attachments?: Array<{ content?: string }> };
39+
} | null;
40+
expect(tool?.name).toBe("sessions_spawn");
41+
expect(tool?.arguments?.attachments?.[0]?.content).toBe("__OPENCLAW_REDACTED__");
42+
expect(JSON.stringify(out)).not.toContain(secret);
43+
});
44+
45+
it("redacts attachments content from tool input payloads too", () => {
46+
const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST";
47+
const input = [
48+
{
49+
role: "assistant",
50+
content: [
51+
{
52+
type: "toolUse",
53+
id: "call_2",
54+
name: "sessions_spawn",
55+
input: {
56+
task: "do thing",
57+
attachments: [{ name: "x.txt", content: secret }],
58+
},
59+
},
60+
],
61+
},
62+
] as unknown as AgentMessage[];
63+
64+
const out = sanitizeToolCallInputs(input);
65+
const msg = out[0] as { content?: unknown[] };
66+
const tool = (msg.content?.[0] ?? null) as {
67+
// Some providers emit tool calls as `input`/`toolUse`. We normalize to `toolCall` with `arguments`.
68+
input?: { attachments?: Array<{ content?: string }> };
69+
arguments?: { attachments?: Array<{ content?: string }> };
70+
} | null;
71+
expect(
72+
tool?.input?.attachments?.[0]?.content || tool?.arguments?.attachments?.[0]?.content,
73+
).toBe("__OPENCLAW_REDACTED__");
74+
expect(JSON.stringify(out)).not.toContain(secret);
75+
});
76+
});

src/agents/session-transcript-repair.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
sanitizeToolCallInputs,
55
sanitizeToolUseResultPairing,
66
repairToolUseResultPairing,
7+
stripToolResultDetails,
78
} from "./session-transcript-repair.js";
89

910
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
@@ -405,6 +406,57 @@ describe("sanitizeToolCallInputs", () => {
405406
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
406407
});
407408

409+
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
410+
const input = [
411+
{
412+
role: "assistant",
413+
content: [
414+
{
415+
type: "toolUse",
416+
id: "call_1",
417+
name: "sessions_spawn",
418+
input: { task: "hello" },
419+
},
420+
],
421+
},
422+
] as unknown as AgentMessage[];
423+
424+
const out = sanitizeToolCallInputs(input);
425+
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
426+
427+
expect(toolCalls).toHaveLength(1);
428+
expect(Object.hasOwn(toolCalls[0] ?? {}, "input")).toBe(true);
429+
expect(Object.hasOwn(toolCalls[0] ?? {}, "arguments")).toBe(false);
430+
expect((toolCalls[0] ?? {}).input).toEqual({ task: "hello" });
431+
});
432+
433+
it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => {
434+
const input = [
435+
{
436+
role: "assistant",
437+
content: [
438+
{
439+
type: "toolUse",
440+
id: "call_1",
441+
name: " SESSIONS_SPAWN ",
442+
input: {
443+
task: "hello",
444+
attachments: [{ name: "a.txt", content: "SECRET" }],
445+
},
446+
},
447+
],
448+
},
449+
] as unknown as AgentMessage[];
450+
451+
const out = sanitizeToolCallInputs(input);
452+
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
453+
454+
expect(toolCalls).toHaveLength(1);
455+
expect((toolCalls[0] ?? {}).name).toBe("SESSIONS_SPAWN");
456+
const inputObj = (toolCalls[0]?.input ?? {}) as Record<string, unknown>;
457+
const attachments = (inputObj.attachments ?? []) as Array<Record<string, unknown>>;
458+
expect(attachments[0]?.content).toBe("__OPENCLAW_REDACTED__");
459+
});
408460
it("preserves other block properties when trimming tool names", () => {
409461
const input = [
410462
{
@@ -424,3 +476,45 @@ describe("sanitizeToolCallInputs", () => {
424476
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({ path: "/tmp/test" });
425477
});
426478
});
479+
480+
describe("stripToolResultDetails", () => {
481+
it("removes details only from toolResult messages", () => {
482+
const input = [
483+
{
484+
role: "toolResult",
485+
toolCallId: "call_1",
486+
toolName: "read",
487+
content: [{ type: "text", text: "ok" }],
488+
details: { internal: true },
489+
},
490+
{ role: "assistant", content: [{ type: "text", text: "keep me" }], details: { no: "touch" } },
491+
{ role: "user", content: "hello" },
492+
] as unknown as AgentMessage[];
493+
494+
const out = stripToolResultDetails(input) as unknown as Array<Record<string, unknown>>;
495+
496+
expect(Object.hasOwn(out[0] ?? {}, "details")).toBe(false);
497+
expect((out[0] ?? {}).role).toBe("toolResult");
498+
499+
// Non-toolResult messages are preserved as-is.
500+
expect(Object.hasOwn(out[1] ?? {}, "details")).toBe(true);
501+
expect((out[1] ?? {}).role).toBe("assistant");
502+
expect((out[2] ?? {}).role).toBe("user");
503+
});
504+
505+
it("returns the same array reference when there are no toolResult details", () => {
506+
const input = [
507+
{ role: "assistant", content: [{ type: "text", text: "a" }] },
508+
{
509+
role: "toolResult",
510+
toolCallId: "call_1",
511+
toolName: "read",
512+
content: [{ type: "text", text: "ok" }],
513+
},
514+
{ role: "user", content: "b" },
515+
] as unknown as AgentMessage[];
516+
517+
const out = stripToolResultDetails(input);
518+
expect(out).toBe(input);
519+
});
520+
});

0 commit comments

Comments
 (0)