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
This commit is contained in:
Gio Della-Libera
2026-05-16 15:56:13 -07:00
committed by GitHub
parent d533a65f56
commit 3b2cd0dd1a
6 changed files with 92 additions and 15 deletions

View File

@@ -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<MaterializeSubagentAttachmentsResult | null> {
@@ -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);

View File

@@ -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<string, unknown> }> = [];
const store: Record<string, Record<string, unknown>> = {};

View File

@@ -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,

View File

@@ -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<string, unknown> } | undefined;
expect(agentCall?.params).not.toHaveProperty("workspaceDir");
});
async function spawnAndReadAgentParams(task: { task: string; lightContext?: boolean }) {
await spawnSubagentDirect(task, {
agentSessionKey: "agent:main:main",

View File

@@ -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");

View File

@@ -481,6 +481,7 @@ export function createSessionsSpawnTool(
agentId: requestedAgentId,
model: modelOverride,
thinking: thinkingOverrideRaw,
cwd,
runTimeoutSeconds,
thread,
mode,