mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 19:14:44 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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>> = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -481,6 +481,7 @@ export function createSessionsSpawnTool(
|
||||
agentId: requestedAgentId,
|
||||
model: modelOverride,
|
||||
thinking: thinkingOverrideRaw,
|
||||
cwd,
|
||||
runTimeoutSeconds,
|
||||
thread,
|
||||
mode,
|
||||
|
||||
Reference in New Issue
Block a user