diff --git a/src/agents/subagent-attachments.ts b/src/agents/subagent-attachments.ts index 7474fd29e82..69bf47123d6 100644 --- a/src/agents/subagent-attachments.ts +++ b/src/agents/subagent-attachments.ts @@ -97,6 +97,7 @@ function resolveAttachmentLimits(config: OpenClawConfig): AttachmentLimits { export async function materializeSubagentAttachments(params: { config: OpenClawConfig; targetAgentId: string; + workspaceDir?: string; attachments?: SubagentInlineAttachment[]; mountPathHint?: string; }): Promise { @@ -121,7 +122,9 @@ export async function materializeSubagentAttachments(params: { } const attachmentId = crypto.randomUUID(); - const childWorkspaceDir = resolveAgentWorkspaceDir(params.config, params.targetAgentId); + const childWorkspaceDir = + normalizeOptionalString(params.workspaceDir) ?? + resolveAgentWorkspaceDir(params.config, params.targetAgentId); const absRootDir = path.join(childWorkspaceDir, ".openclaw", "attachments"); const relDir = path.posix.join(".openclaw", "attachments", attachmentId); const absDir = path.join(absRootDir, attachmentId); diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index 6df17509040..c7249873cfd 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -177,6 +177,31 @@ describe("spawnSubagentDirect filename validation", () => { expect(result.error).toMatch(/attachments_invalid_name/); }); + it("materializes attachments under explicit cwd when native subagent cwd is provided", async () => { + const explicitWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-subagent-cwd-attachments-${process.pid}-${Date.now()}-`), + ); + try { + const { spawnSubagentDirect } = subagentSpawnModule; + const result = await spawnSubagentDirect( + { + task: "test", + cwd: explicitWorkspaceDir, + attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }], + }, + ctx, + ); + + expect(result.status).toBe("accepted"); + const explicitAttachmentsRoot = path.join(explicitWorkspaceDir, ".openclaw", "attachments"); + const targetAttachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments"); + expect(fs.existsSync(explicitAttachmentsRoot)).toBe(true); + expect(fs.existsSync(targetAttachmentsRoot)).toBe(false); + } finally { + fs.rmSync(explicitWorkspaceDir, { recursive: true, force: true }); + } + }); + it("removes materialized attachments when lineage patching fails", async () => { const calls: Array<{ method?: string; params?: Record }> = []; const store: Record> = {}; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 9744fd86f23..87ebb00f251 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -127,6 +127,7 @@ export type SpawnSubagentParams = { model?: string; taskName?: string; thinking?: string; + cwd?: string; runTimeoutSeconds?: number; thread?: boolean; mode?: SpawnSubagentMode; @@ -809,6 +810,7 @@ export async function spawnSubagentDirect( }; } const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId; + const explicitWorkspaceDir = normalizeOptionalString(params.cwd); const requesterOrigin = normalizeDeliveryContext({ channel: ctx.agentChannel, accountId: ctx.agentAccountId, @@ -1035,9 +1037,24 @@ export async function spawnSubagentDirect( | undefined; let attachmentAbsDir: string | undefined; let attachmentRootDir: string | undefined; + const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({ + agentGroupId: ctx.agentGroupId, + agentGroupChannel: ctx.agentGroupChannel, + agentGroupSpace: ctx.agentGroupSpace, + workspaceDir: ctx.workspaceDir, + }); + const inheritedWorkspaceDir = + targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir; + const spawnedWorkspaceDir = resolveSpawnedWorkspaceInheritance({ + config: cfg, + targetAgentId, + explicitWorkspaceDir: explicitWorkspaceDir ?? inheritedWorkspaceDir, + }); + const materializedAttachments = await materializeSubagentAttachments({ config: cfg, targetAgentId, + workspaceDir: spawnedWorkspaceDir, attachments: params.attachments, mountPathHint, }); @@ -1070,23 +1087,10 @@ export async function spawnSubagentDirect( task, }); - const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({ - agentGroupId: ctx.agentGroupId, - agentGroupChannel: ctx.agentGroupChannel, - agentGroupSpace: ctx.agentGroupSpace, - workspaceDir: ctx.workspaceDir, - }); const spawnedMetadata = normalizeSpawnedRunMetadata({ spawnedBy: spawnedByKey, ...toolSpawnMetadata, - workspaceDir: resolveSpawnedWorkspaceInheritance({ - config: cfg, - targetAgentId, - // For cross-agent spawns, ignore the caller's inherited workspace; - // let targetAgentId resolve the correct workspace instead. - explicitWorkspaceDir: - targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir, - }), + workspaceDir: spawnedWorkspaceDir, }); const spawnLineagePatchError = await patchChildSession({ spawnedBy: spawnedByKey, diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index 36783b01236..6eef6cf5c4f 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -165,6 +165,48 @@ describe("spawnSubagentDirect workspace inheritance", () => { }); }); + it("uses explicit cwd for cross-agent native subagent spawns without leaking it to Gateway params", async () => { + hoisted.configOverride = createConfigOverride({ + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + subagents: { + allowAgents: ["ops"], + }, + }, + { + id: "ops", + workspace: "/tmp/workspace-ops", + }, + ], + }, + }); + + const result = await spawnSubagentDirect( + { + task: "inspect explicit cwd", + agentId: "ops", + cwd: "/tmp/requester-workspace", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/fallback-requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()?.workspaceDir).toBe("/tmp/requester-workspace"); + const agentCall = hoisted.callGatewayMock.mock.calls.find( + ([request]) => (request as { method?: string }).method === "agent", + )?.[0] as { params?: Record } | undefined; + expect(agentCall?.params).not.toHaveProperty("workspaceDir"); + }); + async function spawnAndReadAgentParams(task: { task: string; lightContext?: boolean }) { await spawnSubagentDirect(task, { agentSessionKey: "agent:main:main", diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 4dd7aafad28..9276db59835 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -285,6 +285,7 @@ describe("sessions_spawn tool", () => { agentId: "main", model: "anthropic/claude-sonnet-4-6", thinking: "medium", + cwd: "/workspace/requester", runTimeoutSeconds: 5, thread: true, mode: "session", @@ -302,6 +303,7 @@ describe("sessions_spawn tool", () => { expect(spawnArgs.agentId).toBe("main"); expect(spawnArgs.model).toBe("anthropic/claude-sonnet-4-6"); expect(spawnArgs.thinking).toBe("medium"); + expect(spawnArgs.cwd).toBe("/workspace/requester"); expect(spawnArgs.runTimeoutSeconds).toBe(5); expect(spawnArgs.thread).toBe(true); expect(spawnArgs.mode).toBe("session"); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 4de3418db5c..ab1dd153176 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -481,6 +481,7 @@ export function createSessionsSpawnTool( agentId: requestedAgentId, model: modelOverride, thinking: thinkingOverrideRaw, + cwd, runTimeoutSeconds, thread, mode,