Skip to content

Commit 3b2cd0d

Browse files
authored
Honor cwd for native subagent spawns (#81896)
* Honor cwd for native subagent spawns Thread sessions_spawn cwd through the native subagent path, use the resolved child workspace for attachment materialization, and keep workspace metadata internal to the gateway boundary. * Refresh checks after proof update
1 parent d533a65 commit 3b2cd0d

6 files changed

Lines changed: 92 additions & 15 deletions

src/agents/subagent-attachments.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function resolveAttachmentLimits(config: OpenClawConfig): AttachmentLimits {
9797
export async function materializeSubagentAttachments(params: {
9898
config: OpenClawConfig;
9999
targetAgentId: string;
100+
workspaceDir?: string;
100101
attachments?: SubagentInlineAttachment[];
101102
mountPathHint?: string;
102103
}): Promise<MaterializeSubagentAttachmentsResult | null> {
@@ -121,7 +122,9 @@ export async function materializeSubagentAttachments(params: {
121122
}
122123

123124
const attachmentId = crypto.randomUUID();
124-
const childWorkspaceDir = resolveAgentWorkspaceDir(params.config, params.targetAgentId);
125+
const childWorkspaceDir =
126+
normalizeOptionalString(params.workspaceDir) ??
127+
resolveAgentWorkspaceDir(params.config, params.targetAgentId);
125128
const absRootDir = path.join(childWorkspaceDir, ".openclaw", "attachments");
126129
const relDir = path.posix.join(".openclaw", "attachments", attachmentId);
127130
const absDir = path.join(absRootDir, attachmentId);

src/agents/subagent-spawn.attachments.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,31 @@ describe("spawnSubagentDirect filename validation", () => {
177177
expect(result.error).toMatch(/attachments_invalid_name/);
178178
});
179179

180+
it("materializes attachments under explicit cwd when native subagent cwd is provided", async () => {
181+
const explicitWorkspaceDir = fs.mkdtempSync(
182+
path.join(os.tmpdir(), `openclaw-subagent-cwd-attachments-${process.pid}-${Date.now()}-`),
183+
);
184+
try {
185+
const { spawnSubagentDirect } = subagentSpawnModule;
186+
const result = await spawnSubagentDirect(
187+
{
188+
task: "test",
189+
cwd: explicitWorkspaceDir,
190+
attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }],
191+
},
192+
ctx,
193+
);
194+
195+
expect(result.status).toBe("accepted");
196+
const explicitAttachmentsRoot = path.join(explicitWorkspaceDir, ".openclaw", "attachments");
197+
const targetAttachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments");
198+
expect(fs.existsSync(explicitAttachmentsRoot)).toBe(true);
199+
expect(fs.existsSync(targetAttachmentsRoot)).toBe(false);
200+
} finally {
201+
fs.rmSync(explicitWorkspaceDir, { recursive: true, force: true });
202+
}
203+
});
204+
180205
it("removes materialized attachments when lineage patching fails", async () => {
181206
const calls: Array<{ method?: string; params?: Record<string, unknown> }> = [];
182207
const store: Record<string, Record<string, unknown>> = {};

src/agents/subagent-spawn.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export type SpawnSubagentParams = {
127127
model?: string;
128128
taskName?: string;
129129
thinking?: string;
130+
cwd?: string;
130131
runTimeoutSeconds?: number;
131132
thread?: boolean;
132133
mode?: SpawnSubagentMode;
@@ -809,6 +810,7 @@ export async function spawnSubagentDirect(
809810
};
810811
}
811812
const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId;
813+
const explicitWorkspaceDir = normalizeOptionalString(params.cwd);
812814
const requesterOrigin = normalizeDeliveryContext({
813815
channel: ctx.agentChannel,
814816
accountId: ctx.agentAccountId,
@@ -1035,9 +1037,24 @@ export async function spawnSubagentDirect(
10351037
| undefined;
10361038
let attachmentAbsDir: string | undefined;
10371039
let attachmentRootDir: string | undefined;
1040+
const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({
1041+
agentGroupId: ctx.agentGroupId,
1042+
agentGroupChannel: ctx.agentGroupChannel,
1043+
agentGroupSpace: ctx.agentGroupSpace,
1044+
workspaceDir: ctx.workspaceDir,
1045+
});
1046+
const inheritedWorkspaceDir =
1047+
targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir;
1048+
const spawnedWorkspaceDir = resolveSpawnedWorkspaceInheritance({
1049+
config: cfg,
1050+
targetAgentId,
1051+
explicitWorkspaceDir: explicitWorkspaceDir ?? inheritedWorkspaceDir,
1052+
});
1053+
10381054
const materializedAttachments = await materializeSubagentAttachments({
10391055
config: cfg,
10401056
targetAgentId,
1057+
workspaceDir: spawnedWorkspaceDir,
10411058
attachments: params.attachments,
10421059
mountPathHint,
10431060
});
@@ -1070,23 +1087,10 @@ export async function spawnSubagentDirect(
10701087
task,
10711088
});
10721089

1073-
const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({
1074-
agentGroupId: ctx.agentGroupId,
1075-
agentGroupChannel: ctx.agentGroupChannel,
1076-
agentGroupSpace: ctx.agentGroupSpace,
1077-
workspaceDir: ctx.workspaceDir,
1078-
});
10791090
const spawnedMetadata = normalizeSpawnedRunMetadata({
10801091
spawnedBy: spawnedByKey,
10811092
...toolSpawnMetadata,
1082-
workspaceDir: resolveSpawnedWorkspaceInheritance({
1083-
config: cfg,
1084-
targetAgentId,
1085-
// For cross-agent spawns, ignore the caller's inherited workspace;
1086-
// let targetAgentId resolve the correct workspace instead.
1087-
explicitWorkspaceDir:
1088-
targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir,
1089-
}),
1093+
workspaceDir: spawnedWorkspaceDir,
10901094
});
10911095
const spawnLineagePatchError = await patchChildSession({
10921096
spawnedBy: spawnedByKey,

src/agents/subagent-spawn.workspace.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,48 @@ describe("spawnSubagentDirect workspace inheritance", () => {
165165
});
166166
});
167167

168+
it("uses explicit cwd for cross-agent native subagent spawns without leaking it to Gateway params", async () => {
169+
hoisted.configOverride = createConfigOverride({
170+
agents: {
171+
list: [
172+
{
173+
id: "main",
174+
workspace: "/tmp/workspace-main",
175+
subagents: {
176+
allowAgents: ["ops"],
177+
},
178+
},
179+
{
180+
id: "ops",
181+
workspace: "/tmp/workspace-ops",
182+
},
183+
],
184+
},
185+
});
186+
187+
const result = await spawnSubagentDirect(
188+
{
189+
task: "inspect explicit cwd",
190+
agentId: "ops",
191+
cwd: "/tmp/requester-workspace",
192+
},
193+
{
194+
agentSessionKey: "agent:main:main",
195+
agentChannel: "telegram",
196+
agentAccountId: "123",
197+
agentTo: "456",
198+
workspaceDir: "/tmp/fallback-requester-workspace",
199+
},
200+
);
201+
202+
expect(result.status).toBe("accepted");
203+
expect(getRegisteredRun()?.workspaceDir).toBe("/tmp/requester-workspace");
204+
const agentCall = hoisted.callGatewayMock.mock.calls.find(
205+
([request]) => (request as { method?: string }).method === "agent",
206+
)?.[0] as { params?: Record<string, unknown> } | undefined;
207+
expect(agentCall?.params).not.toHaveProperty("workspaceDir");
208+
});
209+
168210
async function spawnAndReadAgentParams(task: { task: string; lightContext?: boolean }) {
169211
await spawnSubagentDirect(task, {
170212
agentSessionKey: "agent:main:main",

src/agents/tools/sessions-spawn-tool.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ describe("sessions_spawn tool", () => {
285285
agentId: "main",
286286
model: "anthropic/claude-sonnet-4-6",
287287
thinking: "medium",
288+
cwd: "/workspace/requester",
288289
runTimeoutSeconds: 5,
289290
thread: true,
290291
mode: "session",
@@ -302,6 +303,7 @@ describe("sessions_spawn tool", () => {
302303
expect(spawnArgs.agentId).toBe("main");
303304
expect(spawnArgs.model).toBe("anthropic/claude-sonnet-4-6");
304305
expect(spawnArgs.thinking).toBe("medium");
306+
expect(spawnArgs.cwd).toBe("/workspace/requester");
305307
expect(spawnArgs.runTimeoutSeconds).toBe(5);
306308
expect(spawnArgs.thread).toBe(true);
307309
expect(spawnArgs.mode).toBe("session");

src/agents/tools/sessions-spawn-tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ export function createSessionsSpawnTool(
481481
agentId: requestedAgentId,
482482
model: modelOverride,
483483
thinking: thinkingOverrideRaw,
484+
cwd,
484485
runTimeoutSeconds,
485486
thread,
486487
mode,

0 commit comments

Comments
 (0)