mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 03:20:23 +00:00
fix: agent-only announce path, BB message IDs, sender identity, SSRF allowlist (#23970)
* fix(agents): defer announces until descendant cleanup settles * fix(bluebubbles): harden message metadata extraction * feat(contributors): rank by composite score (commits, PRs, LOC, tenure) * refactor(control-ui): move method guard after path checks to improve request handling * fix subagent completion announce when only current run is pending * fix(subagents): keep orchestrator runs active until descendants finish * fix: prepare PR feedback follow-ups (#23970) (thanks @tyler6204)
This commit is contained in:
@@ -876,6 +876,59 @@ describe("sessions tools", () => {
|
||||
expect(details.text).toContain("recent (last 30m):");
|
||||
});
|
||||
|
||||
it("subagents list keeps ended orchestrators active while descendants are pending", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const now = Date.now();
|
||||
addSubagentRunForTests({
|
||||
runId: "run-orchestrator-ended",
|
||||
childSessionKey: "agent:main:subagent:orchestrator-ended",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "orchestrate child workers",
|
||||
cleanup: "keep",
|
||||
createdAt: now - 5 * 60_000,
|
||||
startedAt: now - 5 * 60_000,
|
||||
endedAt: now - 4 * 60_000,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
addSubagentRunForTests({
|
||||
runId: "run-orchestrator-child-active",
|
||||
childSessionKey: "agent:main:subagent:orchestrator-ended:subagent:child",
|
||||
requesterSessionKey: "agent:main:subagent:orchestrator-ended",
|
||||
requesterDisplayKey: "subagent:orchestrator-ended",
|
||||
task: "child worker still running",
|
||||
cleanup: "keep",
|
||||
createdAt: now - 60_000,
|
||||
startedAt: now - 60_000,
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools({
|
||||
agentSessionKey: "agent:main:main",
|
||||
}).find((candidate) => candidate.name === "subagents");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error("missing subagents tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call-subagents-list-orchestrator", { action: "list" });
|
||||
const details = result.details as {
|
||||
status?: string;
|
||||
active?: Array<{ runId?: string; status?: string }>;
|
||||
recent?: Array<{ runId?: string }>;
|
||||
};
|
||||
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.active).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
runId: "run-orchestrator-ended",
|
||||
status: "active",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(details.recent?.find((entry) => entry.runId === "run-orchestrator-ended")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("subagents list usage separates io tokens from prompt/cache", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -34,6 +34,8 @@ const embeddedRunMock = {
|
||||
const subagentRegistryMock = {
|
||||
isSubagentSessionRunActive: vi.fn(() => true),
|
||||
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
|
||||
countPendingDescendantRuns: vi.fn((_sessionKey: string) => 0),
|
||||
countPendingDescendantRunsExcludingRun: vi.fn((_sessionKey: string, _runId: string) => 0),
|
||||
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
|
||||
};
|
||||
const subagentDeliveryTargetHookMock = vi.fn(
|
||||
@@ -172,6 +174,16 @@ describe("subagent announce formatting", () => {
|
||||
embeddedRunMock.waitForEmbeddedPiRunEnd.mockClear().mockResolvedValue(true);
|
||||
subagentRegistryMock.isSubagentSessionRunActive.mockClear().mockReturnValue(true);
|
||||
subagentRegistryMock.countActiveDescendantRuns.mockClear().mockReturnValue(0);
|
||||
subagentRegistryMock.countPendingDescendantRuns
|
||||
.mockClear()
|
||||
.mockImplementation((sessionKey: string) =>
|
||||
subagentRegistryMock.countActiveDescendantRuns(sessionKey),
|
||||
);
|
||||
subagentRegistryMock.countPendingDescendantRunsExcludingRun
|
||||
.mockClear()
|
||||
.mockImplementation((sessionKey: string, _runId: string) =>
|
||||
subagentRegistryMock.countPendingDescendantRuns(sessionKey),
|
||||
);
|
||||
subagentRegistryMock.resolveRequesterForChildSession.mockClear().mockReturnValue(null);
|
||||
hasSubagentDeliveryTargetHook = false;
|
||||
hookRunnerMock.hasHooks.mockClear();
|
||||
@@ -408,6 +420,45 @@ describe("subagent announce formatting", () => {
|
||||
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
|
||||
});
|
||||
|
||||
it("keeps direct completion send when only the announcing run itself is pending", async () => {
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-self-pending",
|
||||
},
|
||||
"agent:main:main": {
|
||||
sessionId: "requester-session-self-pending",
|
||||
},
|
||||
};
|
||||
chatHistoryMock.mockResolvedValueOnce({
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: done" }] }],
|
||||
});
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:main" ? 1 : 0,
|
||||
);
|
||||
subagentRegistryMock.countPendingDescendantRunsExcludingRun.mockImplementation(
|
||||
(sessionKey: string, runId: string) =>
|
||||
sessionKey === "agent:main:main" && runId === "run-direct-self-pending" ? 0 : 1,
|
||||
);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-self-pending",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(subagentRegistryMock.countPendingDescendantRunsExcludingRun).toHaveBeenCalledWith(
|
||||
"agent:main:main",
|
||||
"run-direct-self-pending",
|
||||
);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("./pi-embedded.js", () => ({
|
||||
|
||||
vi.mock("./subagent-registry.js", () => ({
|
||||
countActiveDescendantRuns: () => 0,
|
||||
countPendingDescendantRuns: () => 0,
|
||||
isSubagentSessionRunActive: () => true,
|
||||
resolveRequesterForChildSession: () => null,
|
||||
}));
|
||||
|
||||
@@ -728,6 +728,7 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
completionRouteMode?: "bound" | "fallback" | "hook";
|
||||
spawnMode?: SpawnSubagentMode;
|
||||
directIdempotencyKey: string;
|
||||
currentRunId?: string;
|
||||
completionDirectOrigin?: DeliveryContext;
|
||||
directOrigin?: DeliveryContext;
|
||||
requesterIsSubagent: boolean;
|
||||
@@ -770,19 +771,35 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
(params.completionRouteMode === "bound" || params.completionRouteMode === "hook");
|
||||
let shouldSendCompletionDirectly = true;
|
||||
if (!forceBoundSessionDirectDelivery) {
|
||||
let activeDescendantRuns = 0;
|
||||
let pendingDescendantRuns = 0;
|
||||
try {
|
||||
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
|
||||
activeDescendantRuns = Math.max(
|
||||
0,
|
||||
countActiveDescendantRuns(canonicalRequesterSessionKey),
|
||||
);
|
||||
const {
|
||||
countPendingDescendantRuns,
|
||||
countPendingDescendantRunsExcludingRun,
|
||||
countActiveDescendantRuns,
|
||||
} = await import("./subagent-registry.js");
|
||||
if (params.currentRunId && typeof countPendingDescendantRunsExcludingRun === "function") {
|
||||
pendingDescendantRuns = Math.max(
|
||||
0,
|
||||
countPendingDescendantRunsExcludingRun(
|
||||
canonicalRequesterSessionKey,
|
||||
params.currentRunId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pendingDescendantRuns = Math.max(
|
||||
0,
|
||||
typeof countPendingDescendantRuns === "function"
|
||||
? countPendingDescendantRuns(canonicalRequesterSessionKey)
|
||||
: countActiveDescendantRuns(canonicalRequesterSessionKey),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only; when unavailable keep historical direct-send behavior.
|
||||
}
|
||||
// Keep non-bound completion announcements coordinated via requester
|
||||
// session routing while sibling/descendant runs are still active.
|
||||
if (activeDescendantRuns > 0) {
|
||||
// session routing while sibling or descendant runs are still pending.
|
||||
if (pendingDescendantRuns > 0) {
|
||||
shouldSendCompletionDirectly = false;
|
||||
}
|
||||
}
|
||||
@@ -899,6 +916,7 @@ async function deliverSubagentAnnouncement(params: {
|
||||
completionRouteMode?: "bound" | "fallback" | "hook";
|
||||
spawnMode?: SpawnSubagentMode;
|
||||
directIdempotencyKey: string;
|
||||
currentRunId?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SubagentAnnounceDeliveryResult> {
|
||||
return await runSubagentAnnounceDispatch({
|
||||
@@ -922,6 +940,7 @@ async function deliverSubagentAnnouncement(params: {
|
||||
completionMessage: params.completionMessage,
|
||||
internalEvents: params.internalEvents,
|
||||
directIdempotencyKey: params.directIdempotencyKey,
|
||||
currentRunId: params.currentRunId,
|
||||
completionDirectOrigin: params.completionDirectOrigin,
|
||||
completionRouteMode: params.completionRouteMode,
|
||||
spawnMode: params.spawnMode,
|
||||
@@ -1203,16 +1222,23 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
|
||||
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
|
||||
|
||||
let activeChildDescendantRuns = 0;
|
||||
let pendingChildDescendantRuns = 0;
|
||||
try {
|
||||
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
|
||||
activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey));
|
||||
const { countPendingDescendantRuns, countActiveDescendantRuns } =
|
||||
await import("./subagent-registry.js");
|
||||
pendingChildDescendantRuns = Math.max(
|
||||
0,
|
||||
typeof countPendingDescendantRuns === "function"
|
||||
? countPendingDescendantRuns(params.childSessionKey)
|
||||
: countActiveDescendantRuns(params.childSessionKey),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only; fall back to direct announce behavior when unavailable.
|
||||
}
|
||||
if (activeChildDescendantRuns > 0) {
|
||||
// The finished run still has active descendant subagents. Defer announcing
|
||||
// this run until descendants settle so we avoid posting in-progress updates.
|
||||
if (pendingChildDescendantRuns > 0) {
|
||||
// The finished run still has pending descendant subagents (either active,
|
||||
// or ended but still finishing their own announce and cleanup flow). Defer
|
||||
// announcing this run until descendants fully settle.
|
||||
shouldDeleteChildSession = false;
|
||||
return false;
|
||||
}
|
||||
@@ -1383,6 +1409,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
completionRouteMode: completionResolution.routeMode,
|
||||
spawnMode: params.spawnMode,
|
||||
directIdempotencyKey,
|
||||
currentRunId: params.childRunId,
|
||||
signal: params.signal,
|
||||
});
|
||||
// Cron delivery state should only be marked as delivered when we have a
|
||||
|
||||
81
src/agents/subagent-registry-cleanup.test.ts
Normal file
81
src/agents/subagent-registry-cleanup.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDeferredCleanupDecision } from "./subagent-registry-cleanup.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
function makeEntry(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
|
||||
return {
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "test",
|
||||
cleanup: "keep",
|
||||
createdAt: 0,
|
||||
endedAt: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDeferredCleanupDecision", () => {
|
||||
const now = 2_000;
|
||||
|
||||
it("defers completion-message cleanup while descendants are still pending", () => {
|
||||
const decision = resolveDeferredCleanupDecision({
|
||||
entry: makeEntry({ expectsCompletionMessage: true }),
|
||||
now,
|
||||
activeDescendantRuns: 2,
|
||||
announceExpiryMs: 5 * 60_000,
|
||||
announceCompletionHardExpiryMs: 30 * 60_000,
|
||||
maxAnnounceRetryCount: 3,
|
||||
deferDescendantDelayMs: 1_000,
|
||||
resolveAnnounceRetryDelayMs: () => 2_000,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "defer-descendants", delayMs: 1_000 });
|
||||
});
|
||||
|
||||
it("hard-expires completion-message cleanup when descendants never settle", () => {
|
||||
const decision = resolveDeferredCleanupDecision({
|
||||
entry: makeEntry({ expectsCompletionMessage: true, endedAt: now - (30 * 60_000 + 1) }),
|
||||
now,
|
||||
activeDescendantRuns: 1,
|
||||
announceExpiryMs: 5 * 60_000,
|
||||
announceCompletionHardExpiryMs: 30 * 60_000,
|
||||
maxAnnounceRetryCount: 3,
|
||||
deferDescendantDelayMs: 1_000,
|
||||
resolveAnnounceRetryDelayMs: () => 2_000,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "give-up", reason: "expiry" });
|
||||
});
|
||||
|
||||
it("keeps regular expiry behavior for non-completion flows", () => {
|
||||
const decision = resolveDeferredCleanupDecision({
|
||||
entry: makeEntry({ expectsCompletionMessage: false, endedAt: now - (5 * 60_000 + 1) }),
|
||||
now,
|
||||
activeDescendantRuns: 0,
|
||||
announceExpiryMs: 5 * 60_000,
|
||||
announceCompletionHardExpiryMs: 30 * 60_000,
|
||||
maxAnnounceRetryCount: 3,
|
||||
deferDescendantDelayMs: 1_000,
|
||||
resolveAnnounceRetryDelayMs: () => 2_000,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "give-up", reason: "expiry", retryCount: 1 });
|
||||
});
|
||||
|
||||
it("uses retry backoff for completion-message flows once descendants are settled", () => {
|
||||
const decision = resolveDeferredCleanupDecision({
|
||||
entry: makeEntry({ expectsCompletionMessage: true, announceRetryCount: 1 }),
|
||||
now,
|
||||
activeDescendantRuns: 0,
|
||||
announceExpiryMs: 5 * 60_000,
|
||||
announceCompletionHardExpiryMs: 30 * 60_000,
|
||||
maxAnnounceRetryCount: 3,
|
||||
deferDescendantDelayMs: 1_000,
|
||||
resolveAnnounceRetryDelayMs: (retryCount) => retryCount * 1_000,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "retry", retryCount: 2, resumeDelayMs: 2_000 });
|
||||
});
|
||||
});
|
||||
@@ -35,20 +35,27 @@ export function resolveDeferredCleanupDecision(params: {
|
||||
now: number;
|
||||
activeDescendantRuns: number;
|
||||
announceExpiryMs: number;
|
||||
announceCompletionHardExpiryMs: number;
|
||||
maxAnnounceRetryCount: number;
|
||||
deferDescendantDelayMs: number;
|
||||
resolveAnnounceRetryDelayMs: (retryCount: number) => number;
|
||||
}): DeferredCleanupDecision {
|
||||
const endedAgo = resolveEndedAgoMs(params.entry, params.now);
|
||||
if (params.entry.expectsCompletionMessage === true && params.activeDescendantRuns > 0) {
|
||||
if (endedAgo > params.announceExpiryMs) {
|
||||
const isCompletionMessageFlow = params.entry.expectsCompletionMessage === true;
|
||||
const completionHardExpiryExceeded =
|
||||
isCompletionMessageFlow && endedAgo > params.announceCompletionHardExpiryMs;
|
||||
if (isCompletionMessageFlow && params.activeDescendantRuns > 0) {
|
||||
if (completionHardExpiryExceeded) {
|
||||
return { kind: "give-up", reason: "expiry" };
|
||||
}
|
||||
return { kind: "defer-descendants", delayMs: params.deferDescendantDelayMs };
|
||||
}
|
||||
|
||||
const retryCount = (params.entry.announceRetryCount ?? 0) + 1;
|
||||
if (retryCount >= params.maxAnnounceRetryCount || endedAgo > params.announceExpiryMs) {
|
||||
const expiryExceeded = isCompletionMessageFlow
|
||||
? completionHardExpiryExceeded
|
||||
: endedAgo > params.announceExpiryMs;
|
||||
if (retryCount >= params.maxAnnounceRetryCount || expiryExceeded) {
|
||||
return {
|
||||
kind: "give-up",
|
||||
reason: retryCount >= params.maxAnnounceRetryCount ? "retry-limit" : "expiry",
|
||||
|
||||
@@ -113,6 +113,59 @@ export function countActiveDescendantRunsFromRuns(
|
||||
return count;
|
||||
}
|
||||
|
||||
function countPendingDescendantRunsInternal(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
excludeRunId?: string,
|
||||
): number {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return 0;
|
||||
}
|
||||
const excludedRunId = excludeRunId?.trim();
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([root]);
|
||||
let count = 0;
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
const requester = pending[index];
|
||||
if (!requester) {
|
||||
continue;
|
||||
}
|
||||
for (const [runId, entry] of runs.entries()) {
|
||||
if (entry.requesterSessionKey !== requester) {
|
||||
continue;
|
||||
}
|
||||
const runEnded = typeof entry.endedAt === "number";
|
||||
const cleanupCompleted = typeof entry.cleanupCompletedAt === "number";
|
||||
if ((!runEnded || !cleanupCompleted) && runId !== excludedRunId) {
|
||||
count += 1;
|
||||
}
|
||||
const childKey = entry.childSessionKey.trim();
|
||||
if (!childKey || visited.has(childKey)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(childKey);
|
||||
pending.push(childKey);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function countPendingDescendantRunsFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
): number {
|
||||
return countPendingDescendantRunsInternal(runs, rootSessionKey);
|
||||
}
|
||||
|
||||
export function countPendingDescendantRunsExcludingRunFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
excludeRunId: string,
|
||||
): number {
|
||||
return countPendingDescendantRunsInternal(runs, rootSessionKey, excludeRunId);
|
||||
}
|
||||
|
||||
export function listDescendantRunsForRequesterFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
|
||||
@@ -156,6 +156,41 @@ describe("announce loop guard (#18264)", () => {
|
||||
expect(stored?.cleanupCompletedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("expired completion-message entries are still resumed for announce", async () => {
|
||||
announceFn.mockReset();
|
||||
announceFn.mockResolvedValueOnce(true);
|
||||
registry.resetSubagentRegistryForTests();
|
||||
|
||||
const now = Date.now();
|
||||
const runId = "test-expired-completion-message";
|
||||
loadSubagentRegistryFromDisk.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
runId,
|
||||
{
|
||||
runId,
|
||||
childSessionKey: "agent:main:subagent:child-1",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "agent:main:main",
|
||||
task: "completion announce after long descendants",
|
||||
cleanup: "keep" as const,
|
||||
createdAt: now - 20 * 60_000,
|
||||
startedAt: now - 19 * 60_000,
|
||||
endedAt: now - 10 * 60_000,
|
||||
cleanupHandled: false,
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
registry.initSubagentRegistry();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(announceFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("announce rejection resets cleanupHandled so retries can resume", async () => {
|
||||
announceFn.mockReset();
|
||||
announceFn.mockRejectedValueOnce(new Error("announce failed"));
|
||||
|
||||
@@ -162,4 +162,88 @@ describe("subagent registry nested agent tracking", () => {
|
||||
expect(countActiveDescendantRuns("agent:main:main")).toBe(1);
|
||||
expect(countActiveDescendantRuns("agent:main:subagent:orch-ended")).toBe(1);
|
||||
});
|
||||
|
||||
it("countPendingDescendantRuns includes ended descendants until cleanup completes", async () => {
|
||||
const { addSubagentRunForTests, countPendingDescendantRuns } = subagentRegistry;
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-parent-ended-pending",
|
||||
childSessionKey: "agent:main:subagent:orch-pending",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "orchestrate",
|
||||
cleanup: "keep",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
cleanupHandled: false,
|
||||
cleanupCompletedAt: undefined,
|
||||
});
|
||||
addSubagentRunForTests({
|
||||
runId: "run-leaf-ended-pending",
|
||||
childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf",
|
||||
requesterSessionKey: "agent:main:subagent:orch-pending",
|
||||
requesterDisplayKey: "orch-pending",
|
||||
task: "leaf",
|
||||
cleanup: "keep",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
cleanupHandled: true,
|
||||
cleanupCompletedAt: undefined,
|
||||
});
|
||||
|
||||
expect(countPendingDescendantRuns("agent:main:main")).toBe(2);
|
||||
expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-leaf-completed",
|
||||
childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf-completed",
|
||||
requesterSessionKey: "agent:main:subagent:orch-pending",
|
||||
requesterDisplayKey: "orch-pending",
|
||||
task: "leaf complete",
|
||||
cleanup: "keep",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
cleanupHandled: true,
|
||||
cleanupCompletedAt: 3,
|
||||
});
|
||||
expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
|
||||
});
|
||||
|
||||
it("countPendingDescendantRunsExcludingRun ignores only the active announce run", async () => {
|
||||
const { addSubagentRunForTests, countPendingDescendantRunsExcludingRun } = subagentRegistry;
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-self",
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "self",
|
||||
cleanup: "keep",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
cleanupHandled: false,
|
||||
cleanupCompletedAt: undefined,
|
||||
});
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-sibling",
|
||||
childSessionKey: "agent:main:subagent:sibling",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "sibling",
|
||||
cleanup: "keep",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
cleanupHandled: false,
|
||||
cleanupCompletedAt: undefined,
|
||||
});
|
||||
|
||||
expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-self")).toBe(1);
|
||||
expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-sibling")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,7 +537,7 @@ describe("subagent registry steer restarts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits subagent_ended when completion cleanup expires with active descendants", async () => {
|
||||
it("keeps completion cleanup pending while descendants are still active", async () => {
|
||||
announceSpy.mockResolvedValue(false);
|
||||
|
||||
mod.registerSubagentRun({
|
||||
@@ -574,10 +574,11 @@ describe("subagent registry steer restarts", () => {
|
||||
const event = call[0] as { runId?: string; reason?: string };
|
||||
return event.runId === "run-parent-expiry" && event.reason === "subagent-complete";
|
||||
});
|
||||
expect(parentHookCall).toBeDefined();
|
||||
expect(parentHookCall).toBeUndefined();
|
||||
const parent = mod
|
||||
.listSubagentRunsForRequester("agent:main:main")
|
||||
.find((entry) => entry.runId === "run-parent-expiry");
|
||||
expect(parent?.cleanupCompletedAt).toBeTypeOf("number");
|
||||
expect(parent?.cleanupCompletedAt).toBeUndefined();
|
||||
expect(parent?.cleanupHandled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import {
|
||||
countActiveDescendantRunsFromRuns,
|
||||
countActiveRunsForSessionFromRuns,
|
||||
countPendingDescendantRunsExcludingRunFromRuns,
|
||||
countPendingDescendantRunsFromRuns,
|
||||
findRunIdsByChildSessionKeyFromRuns,
|
||||
listDescendantRunsForRequesterFromRuns,
|
||||
listRunsForRequesterFromRuns,
|
||||
@@ -63,10 +65,15 @@ const MAX_ANNOUNCE_RETRY_DELAY_MS = 8_000;
|
||||
*/
|
||||
const MAX_ANNOUNCE_RETRY_COUNT = 3;
|
||||
/**
|
||||
* Announce entries older than this are force-expired even if delivery never
|
||||
* succeeded. Guards against stale registry entries surviving gateway restarts.
|
||||
* Non-completion announce entries older than this are force-expired even if
|
||||
* delivery never succeeded.
|
||||
*/
|
||||
const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes
|
||||
/**
|
||||
* Completion-message flows can wait for descendants to finish, but this hard
|
||||
* cap prevents indefinite pending state when descendants never fully settle.
|
||||
*/
|
||||
const ANNOUNCE_COMPLETION_HARD_EXPIRY_MS = 30 * 60_000; // 30 minutes
|
||||
type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id";
|
||||
/**
|
||||
* Embedded runs can emit transient lifecycle `error` events while provider/model
|
||||
@@ -445,7 +452,11 @@ function resumeSubagentRun(runId: string) {
|
||||
persistSubagentRuns();
|
||||
return;
|
||||
}
|
||||
if (typeof entry.endedAt === "number" && Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS) {
|
||||
if (
|
||||
entry.expectsCompletionMessage !== true &&
|
||||
typeof entry.endedAt === "number" &&
|
||||
Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS
|
||||
) {
|
||||
logAnnounceGiveUp(entry, "expiry");
|
||||
entry.cleanupCompletedAt = Date.now();
|
||||
persistSubagentRuns();
|
||||
@@ -462,6 +473,7 @@ function resumeSubagentRun(runId: string) {
|
||||
) {
|
||||
const waitMs = Math.max(1, earliestRetryAt - now);
|
||||
setTimeout(() => {
|
||||
resumedRuns.delete(runId);
|
||||
resumeSubagentRun(runId);
|
||||
}, waitMs).unref?.();
|
||||
resumedRuns.add(runId);
|
||||
@@ -709,8 +721,10 @@ async function finalizeSubagentCleanup(
|
||||
const deferredDecision = resolveDeferredCleanupDecision({
|
||||
entry,
|
||||
now,
|
||||
activeDescendantRuns: Math.max(0, countActiveDescendantRuns(entry.childSessionKey)),
|
||||
// Defer until descendants are fully settled, including post-end cleanup.
|
||||
activeDescendantRuns: Math.max(0, countPendingDescendantRuns(entry.childSessionKey)),
|
||||
announceExpiryMs: ANNOUNCE_EXPIRY_MS,
|
||||
announceCompletionHardExpiryMs: ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
|
||||
maxAnnounceRetryCount: MAX_ANNOUNCE_RETRY_COUNT,
|
||||
deferDescendantDelayMs: MIN_ANNOUNCE_RETRY_DELAY_MS,
|
||||
resolveAnnounceRetryDelayMs,
|
||||
@@ -753,6 +767,7 @@ async function finalizeSubagentCleanup(
|
||||
// Applies to both keep/delete cleanup modes so delete-runs are only removed
|
||||
// after a successful announce (or terminal give-up).
|
||||
entry.cleanupHandled = false;
|
||||
// Clear the in-flight resume marker so the scheduled retry can run again.
|
||||
resumedRuns.delete(runId);
|
||||
persistSubagentRuns();
|
||||
if (deferredDecision.resumeDelayMs == null) {
|
||||
@@ -815,9 +830,10 @@ function retryDeferredCompletedAnnounces(excludeRunId?: string) {
|
||||
if (suppressAnnounceForSteerRestart(entry)) {
|
||||
continue;
|
||||
}
|
||||
// Force-expire announces that have been pending too long (#18264).
|
||||
// Force-expire stale non-completion announces; completion-message flows can
|
||||
// stay pending while descendants run for a long time.
|
||||
const endedAgo = now - (entry.endedAt ?? now);
|
||||
if (endedAgo > ANNOUNCE_EXPIRY_MS) {
|
||||
if (entry.expectsCompletionMessage !== true && endedAgo > ANNOUNCE_EXPIRY_MS) {
|
||||
logAnnounceGiveUp(entry, "expiry");
|
||||
entry.cleanupCompletedAt = now;
|
||||
persistSubagentRuns();
|
||||
@@ -1214,6 +1230,24 @@ export function countActiveDescendantRuns(rootSessionKey: string): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function countPendingDescendantRuns(rootSessionKey: string): number {
|
||||
return countPendingDescendantRunsFromRuns(
|
||||
getSubagentRunsSnapshotForRead(subagentRuns),
|
||||
rootSessionKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function countPendingDescendantRunsExcludingRun(
|
||||
rootSessionKey: string,
|
||||
excludeRunId: string,
|
||||
): number {
|
||||
return countPendingDescendantRunsExcludingRunFromRuns(
|
||||
getSubagentRunsSnapshotForRead(subagentRuns),
|
||||
rootSessionKey,
|
||||
excludeRunId,
|
||||
);
|
||||
}
|
||||
|
||||
export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] {
|
||||
return listDescendantRunsForRequesterFromRuns(
|
||||
getSubagentRunsSnapshotForRead(subagentRuns),
|
||||
|
||||
@@ -31,6 +31,7 @@ import { optionalStringEnum } from "../schema/typebox.js";
|
||||
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
|
||||
import {
|
||||
clearSubagentRunSteerRestart,
|
||||
countPendingDescendantRuns,
|
||||
listSubagentRunsForRequester,
|
||||
markSubagentRunTerminated,
|
||||
markSubagentRunForSteerRestart,
|
||||
@@ -70,7 +71,10 @@ type ResolvedRequesterKey = {
|
||||
callerIsSubagent: boolean;
|
||||
};
|
||||
|
||||
function resolveRunStatus(entry: SubagentRunRecord) {
|
||||
function resolveRunStatus(entry: SubagentRunRecord, options?: { hasPendingDescendants?: boolean }) {
|
||||
if (options?.hasPendingDescendants) {
|
||||
return "active";
|
||||
}
|
||||
if (!entry.endedAt) {
|
||||
return "running";
|
||||
}
|
||||
@@ -365,6 +369,16 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
const recentCutoff = now - recentMinutes * 60_000;
|
||||
const cache = new Map<string, Record<string, SessionEntry>>();
|
||||
|
||||
const pendingDescendantCache = new Map<string, boolean>();
|
||||
const hasPendingDescendants = (sessionKey: string) => {
|
||||
if (pendingDescendantCache.has(sessionKey)) {
|
||||
return pendingDescendantCache.get(sessionKey) === true;
|
||||
}
|
||||
const hasPending = countPendingDescendantRuns(sessionKey) > 0;
|
||||
pendingDescendantCache.set(sessionKey, hasPending);
|
||||
return hasPending;
|
||||
};
|
||||
|
||||
let index = 1;
|
||||
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
|
||||
const sessionEntry = resolveSessionEntryForKey({
|
||||
@@ -374,7 +388,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
}).entry;
|
||||
const totalTokens = resolveTotalTokens(sessionEntry);
|
||||
const usageText = formatTokenUsageDisplay(sessionEntry);
|
||||
const status = resolveRunStatus(entry);
|
||||
const status = resolveRunStatus(entry, {
|
||||
hasPendingDescendants: hasPendingDescendants(entry.childSessionKey),
|
||||
});
|
||||
const runtime = formatDurationCompact(runtimeMs);
|
||||
const label = truncateLine(resolveSubagentLabel(entry), 48);
|
||||
const task = truncateLine(entry.task.trim(), 72);
|
||||
@@ -396,10 +412,15 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
|
||||
return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
|
||||
};
|
||||
const active = runs
|
||||
.filter((entry) => !entry.endedAt)
|
||||
.filter((entry) => !entry.endedAt || hasPendingDescendants(entry.childSessionKey))
|
||||
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
|
||||
const recent = runs
|
||||
.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff)
|
||||
.filter(
|
||||
(entry) =>
|
||||
!!entry.endedAt &&
|
||||
!hasPendingDescendants(entry.childSessionKey) &&
|
||||
(entry.endedAt ?? 0) >= recentCutoff,
|
||||
)
|
||||
.map((entry) =>
|
||||
buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,14 @@ function parseConversationInfoPayload(text: string): Record<string, unknown> {
|
||||
return JSON.parse(match[1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseSenderInfoPayload(text: string): Record<string, unknown> {
|
||||
const match = text.match(/Sender \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error("missing sender info json block");
|
||||
}
|
||||
return JSON.parse(match[1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("buildInboundMetaSystemPrompt", () => {
|
||||
it("includes session-stable routing fields", () => {
|
||||
const prompt = buildInboundMetaSystemPrompt({
|
||||
@@ -147,6 +155,29 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
expect(conversationInfo["sender"]).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("prefers SenderName in conversation info sender identity", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
SenderName: " Tyler ",
|
||||
SenderId: " +15551234567 ",
|
||||
} as TemplateContext);
|
||||
|
||||
const conversationInfo = parseConversationInfoPayload(text);
|
||||
expect(conversationInfo["sender"]).toBe("Tyler");
|
||||
});
|
||||
|
||||
it("includes sender metadata block for direct chats", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "direct",
|
||||
SenderName: "Tyler",
|
||||
SenderId: "+15551234567",
|
||||
} as TemplateContext);
|
||||
|
||||
const senderInfo = parseSenderInfoPayload(text);
|
||||
expect(senderInfo["label"]).toBe("Tyler (+15551234567)");
|
||||
expect(senderInfo["id"]).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("includes formatted timestamp in conversation info when provided", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
@@ -187,7 +218,7 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
expect(conversationInfo["message_id"]).toBe("msg-123");
|
||||
});
|
||||
|
||||
it("includes message_id_full when it differs from message_id", () => {
|
||||
it("prefers MessageSid when both MessageSid and MessageSidFull are present", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
MessageSid: "short-id",
|
||||
@@ -196,18 +227,18 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
|
||||
const conversationInfo = parseConversationInfoPayload(text);
|
||||
expect(conversationInfo["message_id"]).toBe("short-id");
|
||||
expect(conversationInfo["message_id_full"]).toBe("full-provider-message-id");
|
||||
expect(conversationInfo["message_id_full"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits message_id_full when it matches message_id", () => {
|
||||
it("falls back to MessageSidFull when MessageSid is missing", () => {
|
||||
const text = buildInboundUserContextPrefix({
|
||||
ChatType: "group",
|
||||
MessageSid: "same-id",
|
||||
MessageSidFull: "same-id",
|
||||
MessageSid: " ",
|
||||
MessageSidFull: "full-provider-message-id",
|
||||
} as TemplateContext);
|
||||
|
||||
const conversationInfo = parseConversationInfoPayload(text);
|
||||
expect(conversationInfo["message_id"]).toBe("same-id");
|
||||
expect(conversationInfo["message_id"]).toBe("full-provider-message-id");
|
||||
expect(conversationInfo["message_id_full"]).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -88,21 +88,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
|
||||
const messageId = safeTrim(ctx.MessageSid);
|
||||
const messageIdFull = safeTrim(ctx.MessageSidFull);
|
||||
const resolvedMessageId = messageId ?? messageIdFull;
|
||||
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
|
||||
|
||||
const conversationInfo = {
|
||||
message_id: isDirect ? undefined : messageId,
|
||||
message_id_full: isDirect
|
||||
? undefined
|
||||
: messageIdFull && messageIdFull !== messageId
|
||||
? messageIdFull
|
||||
: undefined,
|
||||
message_id: isDirect ? undefined : resolvedMessageId,
|
||||
reply_to_id: isDirect ? undefined : safeTrim(ctx.ReplyToId),
|
||||
sender_id: isDirect ? undefined : safeTrim(ctx.SenderId),
|
||||
conversation_label: isDirect ? undefined : safeTrim(ctx.ConversationLabel),
|
||||
sender: isDirect
|
||||
? undefined
|
||||
: (safeTrim(ctx.SenderE164) ?? safeTrim(ctx.SenderId) ?? safeTrim(ctx.SenderUsername)),
|
||||
: (safeTrim(ctx.SenderName) ??
|
||||
safeTrim(ctx.SenderE164) ??
|
||||
safeTrim(ctx.SenderId) ??
|
||||
safeTrim(ctx.SenderUsername)),
|
||||
timestamp: timestampStr,
|
||||
group_subject: safeTrim(ctx.GroupSubject),
|
||||
group_channel: safeTrim(ctx.GroupChannel),
|
||||
@@ -131,20 +130,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
);
|
||||
}
|
||||
|
||||
const senderInfo = isDirect
|
||||
? undefined
|
||||
: {
|
||||
label: resolveSenderLabel({
|
||||
name: safeTrim(ctx.SenderName),
|
||||
username: safeTrim(ctx.SenderUsername),
|
||||
tag: safeTrim(ctx.SenderTag),
|
||||
e164: safeTrim(ctx.SenderE164),
|
||||
}),
|
||||
name: safeTrim(ctx.SenderName),
|
||||
username: safeTrim(ctx.SenderUsername),
|
||||
tag: safeTrim(ctx.SenderTag),
|
||||
e164: safeTrim(ctx.SenderE164),
|
||||
};
|
||||
const senderInfo = {
|
||||
label: resolveSenderLabel({
|
||||
name: safeTrim(ctx.SenderName),
|
||||
username: safeTrim(ctx.SenderUsername),
|
||||
tag: safeTrim(ctx.SenderTag),
|
||||
e164: safeTrim(ctx.SenderE164),
|
||||
id: safeTrim(ctx.SenderId),
|
||||
}),
|
||||
id: safeTrim(ctx.SenderId),
|
||||
name: safeTrim(ctx.SenderName),
|
||||
username: safeTrim(ctx.SenderUsername),
|
||||
tag: safeTrim(ctx.SenderTag),
|
||||
e164: safeTrim(ctx.SenderE164),
|
||||
};
|
||||
if (senderInfo?.label) {
|
||||
blocks.push(
|
||||
["Sender (untrusted metadata):", "```json", JSON.stringify(senderInfo, null, 2), "```"].join(
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
function runControlUiRequest(params: {
|
||||
url: string;
|
||||
method: "GET" | "HEAD";
|
||||
method: "GET" | "HEAD" | "POST";
|
||||
rootPath: string;
|
||||
basePath?: string;
|
||||
}) {
|
||||
@@ -356,6 +356,36 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through POST requests when basePath is empty", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { handled, end } = runControlUiRequest({
|
||||
url: "/webhook/bluebubbles",
|
||||
method: "POST",
|
||||
rootPath: tmp,
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(end).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 405 for POST requests under configured basePath", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { handled, res, end } = runControlUiRequest({
|
||||
url: "/openclaw/",
|
||||
method: "POST",
|
||||
rootPath: tmp,
|
||||
basePath: "/openclaw",
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(405);
|
||||
expect(end).toHaveBeenCalledWith("Method Not Allowed");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute-path escape attempts under basePath routes", async () => {
|
||||
await withBasePathRootFixture({
|
||||
siblingDir: "ui-secrets",
|
||||
|
||||
@@ -275,13 +275,6 @@ export function handleControlUiHttpRequest(
|
||||
if (!urlRaw) {
|
||||
return false;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const url = new URL(urlRaw, "http://localhost");
|
||||
const basePath = normalizeControlUiBasePath(opts?.basePath);
|
||||
const pathname = url.pathname;
|
||||
@@ -315,6 +308,19 @@ export function handleControlUiHttpRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// Method guard must run AFTER path checks so that POST requests to non-UI
|
||||
// paths (channel webhooks etc.) fall through to later handlers. When no
|
||||
// basePath is configured the SPA catch-all would otherwise 405 every POST.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
if (!basePath) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
applyControlUiSecurityHeaders(res);
|
||||
|
||||
const bootstrapConfigPath = basePath
|
||||
|
||||
Reference in New Issue
Block a user