mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
Gateway: keep spawned workspace overrides internal (#43801)
* Gateway: keep spawned workspace overrides internal * Changelog: note GHSA-2rqg agent boundary fix * Gateway: persist spawned workspace inheritance in sessions * Agents: clean failed lineage spawn state * Tests: cover lineage attachment cleanup * Tests: cover lineage thread cleanup
This commit is contained in:
@@ -85,7 +85,10 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
});
|
||||
|
||||
it("rejects spawning when caller depth reaches maxSpawnDepth", async () => {
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:subagent:parent",
|
||||
workspaceDir: "/parent/workspace",
|
||||
});
|
||||
const result = await tool.execute("call-depth-reject", { task: "hello" });
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
@@ -109,8 +112,13 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
const calls = callGatewayMock.mock.calls.map(
|
||||
(call) => call[0] as { method?: string; params?: Record<string, unknown> },
|
||||
);
|
||||
const agentCall = calls.find((entry) => entry.method === "agent");
|
||||
expect(agentCall?.params?.spawnedBy).toBe("agent:main:subagent:parent");
|
||||
const spawnedByPatch = calls.find(
|
||||
(entry) =>
|
||||
entry.method === "sessions.patch" &&
|
||||
entry.params?.spawnedBy === "agent:main:subagent:parent",
|
||||
);
|
||||
expect(spawnedByPatch?.params?.key).toMatch(/^agent:main:subagent:/);
|
||||
expect(typeof spawnedByPatch?.params?.spawnedWorkspaceDir).toBe("string");
|
||||
|
||||
const spawnDepthPatch = calls.find(
|
||||
(entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2,
|
||||
|
||||
@@ -380,4 +380,36 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
emitLifecycleHooks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up the provisional session when lineage patching fails after thread binding", async () => {
|
||||
const callGatewayMock = getCallGatewayMock();
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
if (request.method === "sessions.patch" && typeof request.params?.spawnedBy === "string") {
|
||||
throw new Error("lineage patch failed");
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await executeDiscordThreadSessionSpawn("call9");
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
error: "lineage patch failed",
|
||||
});
|
||||
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
|
||||
expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
const methods = getGatewayMethods();
|
||||
expect(methods).toContain("sessions.delete");
|
||||
expect(methods).not.toContain("agent");
|
||||
const deleteCall = findGatewayRequest("sessions.delete");
|
||||
expect(deleteCall?.params).toMatchObject({
|
||||
key: (result.details as { childSessionKey?: string }).childSessionKey,
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
import { decodeStrictBase64, spawnSubagentDirect } from "./subagent-spawn.js";
|
||||
|
||||
@@ -31,6 +32,7 @@ let configOverride: Record<string, unknown> = {
|
||||
},
|
||||
},
|
||||
};
|
||||
let workspaceDirOverride = "";
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -61,7 +63,7 @@ vi.mock("./agent-scope.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./agent-scope.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveAgentWorkspaceDir: () => path.join(os.tmpdir(), "agent-workspace"),
|
||||
resolveAgentWorkspaceDir: () => workspaceDirOverride,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -145,6 +147,16 @@ describe("spawnSubagentDirect filename validation", () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockClear();
|
||||
setupGatewayMock();
|
||||
workspaceDirOverride = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `openclaw-subagent-attachments-${process.pid}-${Date.now()}-`),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (workspaceDirOverride) {
|
||||
fs.rmSync(workspaceDirOverride, { recursive: true, force: true });
|
||||
workspaceDirOverride = "";
|
||||
}
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
@@ -210,4 +222,43 @@ describe("spawnSubagentDirect filename validation", () => {
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toMatch(/attachments_invalid_name/);
|
||||
});
|
||||
|
||||
it("removes materialized attachments when lineage patching fails", async () => {
|
||||
const calls: Array<{ method?: string; params?: Record<string, unknown> }> = [];
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
calls.push(request);
|
||||
if (request.method === "sessions.patch" && typeof request.params?.spawnedBy === "string") {
|
||||
throw new Error("lineage patch failed");
|
||||
}
|
||||
if (request.method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task: "test",
|
||||
attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "error",
|
||||
error: "lineage patch failed",
|
||||
});
|
||||
const attachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments");
|
||||
const retainedDirs = fs.existsSync(attachmentsRoot)
|
||||
? fs.readdirSync(attachmentsRoot).filter((entry) => !entry.startsWith("."))
|
||||
: [];
|
||||
expect(retainedDirs).toHaveLength(0);
|
||||
const deleteCall = calls.find((entry) => entry.method === "sessions.delete");
|
||||
expect(deleteCall?.params).toMatchObject({
|
||||
key: expect.stringMatching(/^agent:main:subagent:/),
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,25 @@ async function cleanupProvisionalSession(
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFailedSpawnBeforeAgentStart(params: {
|
||||
childSessionKey: string;
|
||||
attachmentAbsDir?: string;
|
||||
emitLifecycleHooks?: boolean;
|
||||
deleteTranscript?: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.attachmentAbsDir) {
|
||||
try {
|
||||
await fs.rm(params.attachmentAbsDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
await cleanupProvisionalSession(params.childSessionKey, {
|
||||
emitLifecycleHooks: params.emitLifecycleHooks,
|
||||
deleteTranscript: params.deleteTranscript,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSpawnMode(params: {
|
||||
requestedMode?: SpawnSubagentMode;
|
||||
threadRequested: boolean;
|
||||
@@ -561,10 +580,32 @@ export async function spawnSubagentDirect(
|
||||
explicitWorkspaceDir: toolSpawnMetadata.workspaceDir,
|
||||
}),
|
||||
});
|
||||
const spawnLineagePatchError = await patchChildSession({
|
||||
spawnedBy: spawnedByKey,
|
||||
...(spawnedMetadata.workspaceDir ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir } : {}),
|
||||
});
|
||||
if (spawnLineagePatchError) {
|
||||
await cleanupFailedSpawnBeforeAgentStart({
|
||||
childSessionKey,
|
||||
attachmentAbsDir,
|
||||
emitLifecycleHooks: threadBindingReady,
|
||||
deleteTranscript: true,
|
||||
});
|
||||
return {
|
||||
status: "error",
|
||||
error: spawnLineagePatchError,
|
||||
childSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
const childIdem = crypto.randomUUID();
|
||||
let childRunId: string = childIdem;
|
||||
try {
|
||||
const {
|
||||
spawnedBy: _spawnedBy,
|
||||
workspaceDir: _workspaceDir,
|
||||
...publicSpawnedMetadata
|
||||
} = spawnedMetadata;
|
||||
const response = await callGateway<{ runId: string }>({
|
||||
method: "agent",
|
||||
params: {
|
||||
@@ -581,7 +622,7 @@ export async function spawnSubagentDirect(
|
||||
thinking: thinkingOverride,
|
||||
timeout: runTimeoutSeconds,
|
||||
label: label || undefined,
|
||||
...spawnedMetadata,
|
||||
...publicSpawnedMetadata,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user