mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:54:33 +00:00
fix(core): unify session-key normalization and plugin boundary checks
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveCronAgentSessionKey } from "./run.js";
|
import { resolveCronAgentSessionKey } from "./session-key.js";
|
||||||
|
|
||||||
describe("resolveCronAgentSessionKey", () => {
|
describe("resolveCronAgentSessionKey", () => {
|
||||||
it("builds an agent-scoped key for legacy aliases", () => {
|
it("builds an agent-scoped key for legacy aliases", () => {
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ import {
|
|||||||
import type { AgentDefaultsConfig } from "../../config/types.js";
|
import type { AgentDefaultsConfig } from "../../config/types.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { logWarn } from "../../logger.js";
|
import { logWarn } from "../../logger.js";
|
||||||
import {
|
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||||
buildAgentMainSessionKey,
|
|
||||||
normalizeAgentId,
|
|
||||||
parseAgentSessionKey,
|
|
||||||
} from "../../routing/session-key.js";
|
|
||||||
import {
|
import {
|
||||||
buildSafeExternalPrompt,
|
buildSafeExternalPrompt,
|
||||||
detectSuspiciousPatterns,
|
detectSuspiciousPatterns,
|
||||||
@@ -67,6 +63,7 @@ import {
|
|||||||
pickSummaryFromPayloads,
|
pickSummaryFromPayloads,
|
||||||
resolveHeartbeatAckMaxChars,
|
resolveHeartbeatAckMaxChars,
|
||||||
} from "./helpers.js";
|
} from "./helpers.js";
|
||||||
|
import { resolveCronAgentSessionKey } from "./session-key.js";
|
||||||
import { resolveCronSession } from "./session.js";
|
import { resolveCronSession } from "./session.js";
|
||||||
import { resolveCronSkillsSnapshot } from "./skills-snapshot.js";
|
import { resolveCronSkillsSnapshot } from "./skills-snapshot.js";
|
||||||
|
|
||||||
@@ -647,18 +644,3 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
|
|
||||||
return resolveRunOutcome({ delivered, deliveryAttempted });
|
return resolveRunOutcome({ delivered, deliveryAttempted });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCronAgentSessionKey(params: {
|
|
||||||
sessionKey: string;
|
|
||||||
agentId: string;
|
|
||||||
}): string {
|
|
||||||
const baseSessionKey = params.sessionKey.trim();
|
|
||||||
const normalizedBaseSessionKey = baseSessionKey.toLowerCase();
|
|
||||||
if (parseAgentSessionKey(normalizedBaseSessionKey)) {
|
|
||||||
return normalizedBaseSessionKey;
|
|
||||||
}
|
|
||||||
return buildAgentMainSessionKey({
|
|
||||||
agentId: params.agentId,
|
|
||||||
mainKey: baseSessionKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
13
src/cron/isolated-agent/session-key.ts
Normal file
13
src/cron/isolated-agent/session-key.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { toAgentStoreSessionKey } from "../../routing/session-key.js";
|
||||||
|
|
||||||
|
export function resolveCronAgentSessionKey(params: {
|
||||||
|
sessionKey: string;
|
||||||
|
agentId: string;
|
||||||
|
mainKey?: string | undefined;
|
||||||
|
}): string {
|
||||||
|
return toAgentStoreSessionKey({
|
||||||
|
agentId: params.agentId,
|
||||||
|
requestKey: params.sessionKey.trim(),
|
||||||
|
mainKey: params.mainKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"
|
|||||||
import {
|
import {
|
||||||
extractHookToken,
|
extractHookToken,
|
||||||
isHookAgentAllowed,
|
isHookAgentAllowed,
|
||||||
|
normalizeHookDispatchSessionKey,
|
||||||
resolveHookSessionKey,
|
resolveHookSessionKey,
|
||||||
resolveHookTargetAgentId,
|
resolveHookTargetAgentId,
|
||||||
normalizeAgentPayload,
|
normalizeAgentPayload,
|
||||||
@@ -280,6 +281,24 @@ describe("gateway hooks helpers", () => {
|
|||||||
expect(resolvedKey).toEqual({ ok: true, value: "hook:ingress" });
|
expect(resolvedKey).toEqual({ ok: true, value: "hook:ingress" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizeHookDispatchSessionKey strips duplicate target agent prefix", () => {
|
||||||
|
expect(
|
||||||
|
normalizeHookDispatchSessionKey({
|
||||||
|
sessionKey: "agent:hooks:slack:channel:c123",
|
||||||
|
targetAgentId: "hooks",
|
||||||
|
}),
|
||||||
|
).toBe("slack:channel:c123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeHookDispatchSessionKey preserves non-target agent scoped keys", () => {
|
||||||
|
expect(
|
||||||
|
normalizeHookDispatchSessionKey({
|
||||||
|
sessionKey: "agent:main:slack:channel:c123",
|
||||||
|
targetAgentId: "hooks",
|
||||||
|
}),
|
||||||
|
).toBe("agent:main:slack:channel:c123");
|
||||||
|
});
|
||||||
|
|
||||||
test("resolveHooksConfig validates defaultSessionKey and generated fallback against prefixes", () => {
|
test("resolveHooksConfig validates defaultSessionKey and generated fallback against prefixes", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveHooksConfig({
|
resolveHooksConfig({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js";
|
import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||||
import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
|
import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
|
||||||
|
|
||||||
@@ -332,6 +332,25 @@ export function resolveHookSessionKey(params: {
|
|||||||
return { ok: true, value: generated };
|
return { ok: true, value: generated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeHookDispatchSessionKey(params: {
|
||||||
|
sessionKey: string;
|
||||||
|
targetAgentId: string | undefined;
|
||||||
|
}): string {
|
||||||
|
const trimmed = params.sessionKey.trim();
|
||||||
|
if (!trimmed || !params.targetAgentId) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
const parsed = parseAgentSessionKey(trimmed);
|
||||||
|
if (!parsed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
const targetAgentId = normalizeAgentId(params.targetAgentId);
|
||||||
|
if (parsed.agentId !== targetAgentId) {
|
||||||
|
return `agent:${parsed.agentId}:${parsed.rest}`;
|
||||||
|
}
|
||||||
|
return parsed.rest;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAgentPayload(payload: Record<string, unknown>):
|
export function normalizeAgentPayload(payload: Record<string, unknown>):
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
normalizeHookHeaders,
|
normalizeHookHeaders,
|
||||||
normalizeWakePayload,
|
normalizeWakePayload,
|
||||||
readJsonBody,
|
readJsonBody,
|
||||||
|
normalizeHookDispatchSessionKey,
|
||||||
resolveHookSessionKey,
|
resolveHookSessionKey,
|
||||||
resolveHookTargetAgentId,
|
resolveHookTargetAgentId,
|
||||||
resolveHookChannel,
|
resolveHookChannel,
|
||||||
@@ -355,10 +356,14 @@ export function createHooksRequestHandler(
|
|||||||
sendJson(res, 400, { ok: false, error: sessionKey.error });
|
sendJson(res, 400, { ok: false, error: sessionKey.error });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const targetAgentId = resolveHookTargetAgentId(hooksConfig, normalized.value.agentId);
|
||||||
const runId = dispatchAgentHook({
|
const runId = dispatchAgentHook({
|
||||||
...normalized.value,
|
...normalized.value,
|
||||||
sessionKey: sessionKey.value,
|
sessionKey: normalizeHookDispatchSessionKey({
|
||||||
agentId: resolveHookTargetAgentId(hooksConfig, normalized.value.agentId),
|
sessionKey: sessionKey.value,
|
||||||
|
targetAgentId,
|
||||||
|
}),
|
||||||
|
agentId: targetAgentId,
|
||||||
});
|
});
|
||||||
sendJson(res, 202, { ok: true, runId });
|
sendJson(res, 202, { ok: true, runId });
|
||||||
return true;
|
return true;
|
||||||
@@ -408,12 +413,16 @@ export function createHooksRequestHandler(
|
|||||||
sendJson(res, 400, { ok: false, error: sessionKey.error });
|
sendJson(res, 400, { ok: false, error: sessionKey.error });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const targetAgentId = resolveHookTargetAgentId(hooksConfig, mapped.action.agentId);
|
||||||
const runId = dispatchAgentHook({
|
const runId = dispatchAgentHook({
|
||||||
message: mapped.action.message,
|
message: mapped.action.message,
|
||||||
name: mapped.action.name ?? "Hook",
|
name: mapped.action.name ?? "Hook",
|
||||||
agentId: resolveHookTargetAgentId(hooksConfig, mapped.action.agentId),
|
agentId: targetAgentId,
|
||||||
wakeMode: mapped.action.wakeMode,
|
wakeMode: mapped.action.wakeMode,
|
||||||
sessionKey: sessionKey.value,
|
sessionKey: normalizeHookDispatchSessionKey({
|
||||||
|
sessionKey: sessionKey.value,
|
||||||
|
targetAgentId,
|
||||||
|
}),
|
||||||
deliver: resolveHookDeliver(mapped.action.deliver),
|
deliver: resolveHookDeliver(mapped.action.deliver),
|
||||||
channel,
|
channel,
|
||||||
to: mapped.action.to,
|
to: mapped.action.to,
|
||||||
|
|||||||
@@ -299,6 +299,48 @@ describe("gateway server hooks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes duplicate target-agent prefixes before isolated dispatch", async () => {
|
||||||
|
testState.hooksConfig = {
|
||||||
|
enabled: true,
|
||||||
|
token: "hook-secret",
|
||||||
|
allowRequestSessionKey: true,
|
||||||
|
allowedSessionKeyPrefixes: ["hook:", "agent:"],
|
||||||
|
};
|
||||||
|
testState.agentsConfig = {
|
||||||
|
list: [{ id: "main", default: true }, { id: "hooks" }],
|
||||||
|
};
|
||||||
|
await withGatewayServer(async ({ port }) => {
|
||||||
|
cronIsolatedRun.mockClear();
|
||||||
|
cronIsolatedRun.mockResolvedValueOnce({
|
||||||
|
status: "ok",
|
||||||
|
summary: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resAgent = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer hook-secret",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Do it",
|
||||||
|
name: "Email",
|
||||||
|
agentId: "hooks",
|
||||||
|
sessionKey: "agent:hooks:slack:channel:c123",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resAgent.status).toBe(202);
|
||||||
|
await waitForSystemEvent();
|
||||||
|
|
||||||
|
const routedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
|
||||||
|
| { sessionKey?: string; job?: { agentId?: string } }
|
||||||
|
| undefined;
|
||||||
|
expect(routedCall?.job?.agentId).toBe("hooks");
|
||||||
|
expect(routedCall?.sessionKey).toBe("slack:channel:c123");
|
||||||
|
drainSystemEvents(resolveMainKey());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
|
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
|
||||||
testState.hooksConfig = {
|
testState.hooksConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import type { CronJob } from "../../cron/types.js";
|
|||||||
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
|
||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
import type { HookAgentDispatchPayload, HooksConfigResolved } from "../hooks.js";
|
import {
|
||||||
|
normalizeHookDispatchSessionKey,
|
||||||
|
type HookAgentDispatchPayload,
|
||||||
|
type HooksConfigResolved,
|
||||||
|
} from "../hooks.js";
|
||||||
import { createHooksRequestHandler } from "../server-http.js";
|
import { createHooksRequestHandler } from "../server-http.js";
|
||||||
|
|
||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
@@ -30,7 +34,10 @@ export function createGatewayHooksRequestHandler(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dispatchAgentHook = (value: HookAgentDispatchPayload) => {
|
const dispatchAgentHook = (value: HookAgentDispatchPayload) => {
|
||||||
const sessionKey = value.sessionKey.trim();
|
const sessionKey = normalizeHookDispatchSessionKey({
|
||||||
|
sessionKey: value.sessionKey,
|
||||||
|
targetAgentId: value.agentId,
|
||||||
|
});
|
||||||
const mainSessionKey = resolveMainSessionKeyFromConfig();
|
const mainSessionKey = resolveMainSessionKeyFromConfig();
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -295,6 +295,32 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
|
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads plugins when source and root differ only by realpath alias", () => {
|
||||||
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||||
|
const plugin = writePlugin({
|
||||||
|
id: "alias-safe",
|
||||||
|
body: `export default { id: "alias-safe", register() {} };`,
|
||||||
|
});
|
||||||
|
const realRoot = fs.realpathSync(plugin.dir);
|
||||||
|
if (realRoot === plugin.dir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = loadOpenClawPlugins({
|
||||||
|
cache: false,
|
||||||
|
workspaceDir: plugin.dir,
|
||||||
|
config: {
|
||||||
|
plugins: {
|
||||||
|
load: { paths: [plugin.file] },
|
||||||
|
allow: ["alias-safe"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = registry.plugins.find((entry) => entry.id === "alias-safe");
|
||||||
|
expect(loaded?.status).toBe("loaded");
|
||||||
|
});
|
||||||
|
|
||||||
it("denylist disables plugins even if allowed", () => {
|
it("denylist disables plugins even if allowed", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
|
|||||||
@@ -530,6 +530,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
absolutePath: candidate.source,
|
absolutePath: candidate.source,
|
||||||
rootPath: pluginRoot,
|
rootPath: pluginRoot,
|
||||||
boundaryLabel: "plugin root",
|
boundaryLabel: "plugin root",
|
||||||
|
// Discovery stores rootDir as realpath but source may still be a lexical alias
|
||||||
|
// (e.g. /var/... vs /private/var/... on macOS). Canonical boundary checks
|
||||||
|
// still enforce containment; skip lexical pre-check to avoid false escapes.
|
||||||
|
skipLexicalRootCheck: true,
|
||||||
});
|
});
|
||||||
if (!opened.ok) {
|
if (!opened.ok) {
|
||||||
record.status = "error";
|
record.status = "error";
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
getSubagentDepth,
|
getSubagentDepth,
|
||||||
isCronSessionKey,
|
isCronSessionKey,
|
||||||
} from "../sessions/session-key-utils.js";
|
} from "../sessions/session-key-utils.js";
|
||||||
import { classifySessionKeyShape } from "./session-key.js";
|
import {
|
||||||
|
classifySessionKeyShape,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
toAgentStoreSessionKey,
|
||||||
|
} from "./session-key.js";
|
||||||
|
|
||||||
describe("classifySessionKeyShape", () => {
|
describe("classifySessionKeyShape", () => {
|
||||||
it("classifies empty keys as missing", () => {
|
it("classifies empty keys as missing", () => {
|
||||||
@@ -93,3 +97,21 @@ describe("deriveSessionChatType", () => {
|
|||||||
expect(deriveSessionChatType("")).toBe("unknown");
|
expect(deriveSessionChatType("")).toBe("unknown");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session key canonicalization", () => {
|
||||||
|
it("parses agent keys case-insensitively and returns lowercase tokens", () => {
|
||||||
|
expect(parseAgentSessionKey("AGENT:Main:Hook:Webhook:42")).toEqual({
|
||||||
|
agentId: "main",
|
||||||
|
rest: "hook:webhook:42",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-prefix already-qualified agent keys", () => {
|
||||||
|
expect(
|
||||||
|
toAgentStoreSessionKey({
|
||||||
|
agentId: "main",
|
||||||
|
requestKey: "agent:main:main",
|
||||||
|
}),
|
||||||
|
).toBe("agent:main:main");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,16 +49,17 @@ export function toAgentStoreSessionKey(params: {
|
|||||||
mainKey?: string | undefined;
|
mainKey?: string | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
const raw = (params.requestKey ?? "").trim();
|
const raw = (params.requestKey ?? "").trim();
|
||||||
if (!raw || raw === DEFAULT_MAIN_KEY) {
|
if (!raw || raw.toLowerCase() === DEFAULT_MAIN_KEY) {
|
||||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey });
|
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey });
|
||||||
}
|
}
|
||||||
|
const parsed = parseAgentSessionKey(raw);
|
||||||
|
if (parsed) {
|
||||||
|
return `agent:${parsed.agentId}:${parsed.rest}`;
|
||||||
|
}
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
if (lowered.startsWith("agent:")) {
|
if (lowered.startsWith("agent:")) {
|
||||||
return lowered;
|
return lowered;
|
||||||
}
|
}
|
||||||
if (lowered.startsWith("subagent:")) {
|
|
||||||
return `agent:${normalizeAgentId(params.agentId)}:${lowered}`;
|
|
||||||
}
|
|
||||||
return `agent:${normalizeAgentId(params.agentId)}:${lowered}`;
|
return `agent:${normalizeAgentId(params.agentId)}:${lowered}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ export type ParsedAgentSessionKey = {
|
|||||||
|
|
||||||
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
|
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse agent-scoped session keys in a canonical, case-insensitive way.
|
||||||
|
* Returned values are normalized to lowercase for stable comparisons/routing.
|
||||||
|
*/
|
||||||
export function parseAgentSessionKey(
|
export function parseAgentSessionKey(
|
||||||
sessionKey: string | undefined | null,
|
sessionKey: string | undefined | null,
|
||||||
): ParsedAgentSessionKey | null {
|
): ParsedAgentSessionKey | null {
|
||||||
const raw = (sessionKey ?? "").trim();
|
const raw = (sessionKey ?? "").trim().toLowerCase();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user