diff --git a/src/agents/subagent-control.test.ts b/src/agents/subagent-control.test.ts new file mode 100644 index 00000000000..c7aec343018 --- /dev/null +++ b/src/agents/subagent-control.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + addSubagentRunForTests, + getSubagentRunByChildSessionKey, + resetSubagentRegistryForTests, +} from "./subagent-registry.js"; +import { killSubagentRunAdmin } from "./subagent-control.js"; + +describe("killSubagentRunAdmin", () => { + afterEach(() => { + resetSubagentRegistryForTests({ persist: false }); + }); + + it("kills a subagent by session key without requester ownership checks", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-admin-kill-")); + const storePath = path.join(tmpDir, "sessions.json"); + const childSessionKey = "agent:main:subagent:worker"; + + fs.writeFileSync( + storePath, + JSON.stringify( + { + [childSessionKey]: { + sessionId: "sess-worker", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + "utf-8", + ); + + addSubagentRunForTests({ + runId: "run-worker", + childSessionKey, + controllerSessionKey: "agent:main:other-controller", + requesterSessionKey: "agent:main:other-requester", + requesterDisplayKey: "other-requester", + task: "do the work", + cleanup: "keep", + createdAt: Date.now() - 5_000, + startedAt: Date.now() - 4_000, + }); + + const cfg = { + session: { store: storePath }, + } as OpenClawConfig; + + const result = await killSubagentRunAdmin({ + cfg, + sessionKey: childSessionKey, + }); + + expect(result).toMatchObject({ + found: true, + killed: true, + runId: "run-worker", + sessionKey: childSessionKey, + }); + expect(getSubagentRunByChildSessionKey(childSessionKey)?.endedAt).toBeTypeOf("number"); + }); + + it("returns found=false when the session key is not tracked as a subagent run", async () => { + const result = await killSubagentRunAdmin({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:subagent:missing", + }); + + expect(result).toEqual({ found: false, killed: false }); + }); +}); diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 6fe96e11b6e..5996ba06fcc 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -29,6 +29,7 @@ import { resolveStoredSubagentCapabilities } from "./subagent-capabilities.js"; import { clearSubagentRunSteerRestart, countPendingDescendantRuns, + getSubagentRunByChildSessionKey, listSubagentRunsForController, markSubagentRunTerminated, markSubagentRunForSteerRestart, @@ -530,6 +531,40 @@ export async function killControlledSubagentRun(params: { }; } +export async function killSubagentRunAdmin(params: { cfg: OpenClawConfig; sessionKey: string }) { + const targetSessionKey = params.sessionKey.trim(); + if (!targetSessionKey) { + return { found: false as const, killed: false }; + } + const entry = getSubagentRunByChildSessionKey(targetSessionKey); + if (!entry) { + return { found: false as const, killed: false }; + } + + const killCache = new Map>(); + const stopResult = await killSubagentRun({ + cfg: params.cfg, + entry, + cache: killCache, + }); + const seenChildSessionKeys = new Set([targetSessionKey]); + const cascade = await cascadeKillChildren({ + cfg: params.cfg, + parentChildSessionKey: targetSessionKey, + cache: killCache, + seenChildSessionKeys, + }); + + return { + found: true as const, + killed: stopResult.killed || cascade.killed > 0, + runId: entry.runId, + sessionKey: entry.childSessionKey, + cascadeKilled: cascade.killed, + cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined, + }; +} + export async function steerControlledSubagentRun(params: { cfg: OpenClawConfig; controller: ResolvedSubagentController; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 4a6fc780d4d..e9837cff1b3 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -57,6 +57,7 @@ import { getBearerToken } from "./http-utils.js"; import { resolveRequestClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; +import { handleSessionKillHttpRequest } from "./session-kill-http.js"; import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js"; import { authorizeCanvasRequest, @@ -800,6 +801,16 @@ export function createGatewayHttpServer(opts: { rateLimiter, }), }, + { + name: "sessions-kill", + run: () => + handleSessionKillHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }, { name: "slack", run: () => handleSlackHttpRequest(req, res), diff --git a/src/gateway/session-kill-http.test.ts b/src/gateway/session-kill-http.test.ts new file mode 100644 index 00000000000..c5cd532a0ce --- /dev/null +++ b/src/gateway/session-kill-http.test.ts @@ -0,0 +1,129 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; + +let cfg: Record = {}; +const authMock = vi.fn(async () => ({ ok: true })); +const loadSessionEntryMock = vi.fn(); +const killSubagentRunAdminMock = vi.fn(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => cfg, +})); + +vi.mock("./auth.js", () => ({ + authorizeHttpGatewayConnect: (...args: unknown[]) => authMock(...args), +})); + +vi.mock("./session-utils.js", () => ({ + loadSessionEntry: (...args: unknown[]) => loadSessionEntryMock(...args), +})); + +vi.mock("../agents/subagent-control.js", () => ({ + killSubagentRunAdmin: (...args: unknown[]) => killSubagentRunAdminMock(...args), +})); + +const { handleSessionKillHttpRequest } = await import("./session-kill-http.js"); + +let port = 0; +let server: ReturnType | undefined; + +beforeAll(async () => { + server = createServer((req, res) => { + void handleSessionKillHttpRequest(req, res, { + auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false }, + }).then((handled) => { + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }); + }); + + await new Promise((resolve, reject) => { + server?.once("error", reject); + server?.listen(0, "127.0.0.1", () => { + const address = server?.address() as AddressInfo | null; + if (!address) { + reject(new Error("server missing address")); + return; + } + port = address.port; + resolve(); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server?.close((err) => (err ? reject(err) : resolve())); + }); +}); + +beforeEach(() => { + cfg = {}; + authMock.mockReset(); + authMock.mockResolvedValue({ ok: true }); + loadSessionEntryMock.mockReset(); + killSubagentRunAdminMock.mockReset(); +}); + +async function post(pathname: string, token = TEST_GATEWAY_TOKEN) { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return fetch(`http://127.0.0.1:${port}${pathname}`, { + method: "POST", + headers, + }); +} + +describe("POST /sessions/:sessionKey/kill", () => { + it("returns 401 when auth fails", async () => { + authMock.mockResolvedValueOnce({ ok: false, rateLimited: false }); + + const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + expect(response.status).toBe(401); + }); + + it("returns 404 when the session key is not in the session store", async () => { + loadSessionEntryMock.mockReturnValue({ entry: undefined }); + + const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { type: "not_found" }, + }); + expect(killSubagentRunAdminMock).not.toHaveBeenCalled(); + }); + + it("kills a matching session via the admin kill helper", async () => { + loadSessionEntryMock.mockReturnValue({ + entry: { sessionId: "sess-worker", updatedAt: Date.now() }, + }); + killSubagentRunAdminMock.mockResolvedValue({ found: true, killed: true }); + + const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, killed: true }); + expect(killSubagentRunAdminMock).toHaveBeenCalledWith({ + cfg, + sessionKey: "agent:main:subagent:worker", + }); + }); + + it("returns killed=false when the target exists but nothing was stopped", async () => { + loadSessionEntryMock.mockReturnValue({ + entry: { sessionId: "sess-worker", updatedAt: Date.now() }, + }); + killSubagentRunAdminMock.mockResolvedValue({ found: true, killed: false }); + + const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, killed: false }); + }); +}); diff --git a/src/gateway/session-kill-http.ts b/src/gateway/session-kill-http.ts new file mode 100644 index 00000000000..a16e1fed52e --- /dev/null +++ b/src/gateway/session-kill-http.ts @@ -0,0 +1,85 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { killSubagentRunAdmin } from "../agents/subagent-control.js"; +import { loadConfig } from "../config/config.js"; +import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import { + sendGatewayAuthFailure, + sendJson, + sendMethodNotAllowed, +} from "./http-common.js"; +import { getBearerToken } from "./http-utils.js"; +import { loadSessionEntry } from "./session-utils.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; + +function resolveSessionKeyFromPath(pathname: string): string | null { + const match = pathname.match(/^\/sessions\/([^/]+)\/kill$/); + if (!match) { + return null; + } + try { + const decoded = decodeURIComponent(match[1] ?? "").trim(); + return decoded || null; + } catch { + return null; + } +} + +export async function handleSessionKillHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: { + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + }, +): Promise { + const cfg = loadConfig(); + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const sessionKey = resolveSessionKeyFromPath(url.pathname); + if (!sessionKey) { + return false; + } + + if (req.method !== "POST") { + sendMethodNotAllowed(res, "POST"); + return true; + } + + const token = getBearerToken(req); + const authResult = await authorizeHttpGatewayConnect({ + auth: opts.auth, + connectAuth: token ? { token, password: token } : null, + req, + trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + }); + if (!authResult.ok) { + sendGatewayAuthFailure(res, authResult); + return true; + } + + const { entry } = loadSessionEntry(sessionKey); + if (!entry) { + sendJson(res, 404, { + ok: false, + error: { + type: "not_found", + message: `Session not found: ${sessionKey}`, + }, + }); + return true; + } + + const result = await killSubagentRunAdmin({ + cfg, + sessionKey, + }); + + sendJson(res, 200, { + ok: true, + killed: result.killed, + }); + return true; +}