mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 07:27:28 +00:00
fix(security): harden channel auth path checks and exec approval routing
This commit is contained in:
@@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding) so unauthenticated alternate-path variants cannot bypass gateway auth.
|
||||
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
|
||||
- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
|
||||
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
|
||||
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
|
||||
|
||||
@@ -40,6 +40,10 @@ describe("requestExecApprovalDecision", () => {
|
||||
agentId: "main",
|
||||
resolvedPath: "/usr/bin/echo",
|
||||
sessionKey: "session",
|
||||
turnSourceChannel: "whatsapp",
|
||||
turnSourceTo: "+15555550123",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "1739201675.123",
|
||||
});
|
||||
|
||||
expect(result).toBe("allow-once");
|
||||
@@ -57,6 +61,10 @@ describe("requestExecApprovalDecision", () => {
|
||||
agentId: "main",
|
||||
resolvedPath: "/usr/bin/echo",
|
||||
sessionKey: "session",
|
||||
turnSourceChannel: "whatsapp",
|
||||
turnSourceTo: "+15555550123",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "1739201675.123",
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
twoPhase: true,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,10 @@ export type RequestExecApprovalDecisionParams = {
|
||||
agentId?: string;
|
||||
resolvedPath?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
};
|
||||
|
||||
type ParsedDecision = { present: boolean; value: string | null };
|
||||
@@ -72,6 +76,10 @@ export async function registerExecApprovalRequest(
|
||||
agentId: params.agentId,
|
||||
resolvedPath: params.resolvedPath,
|
||||
sessionKey: params.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
twoPhase: true,
|
||||
},
|
||||
@@ -127,6 +135,10 @@ export async function requestExecApprovalDecisionForHost(params: {
|
||||
agentId?: string;
|
||||
resolvedPath?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
}): Promise<string | null> {
|
||||
return await requestExecApprovalDecision({
|
||||
id: params.approvalId,
|
||||
@@ -140,6 +152,10 @@ export async function requestExecApprovalDecisionForHost(params: {
|
||||
agentId: params.agentId,
|
||||
resolvedPath: params.resolvedPath,
|
||||
sessionKey: params.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +171,10 @@ export async function registerExecApprovalRequestForHost(params: {
|
||||
agentId?: string;
|
||||
resolvedPath?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
}): Promise<ExecApprovalRegistration> {
|
||||
return await registerExecApprovalRequest({
|
||||
id: params.approvalId,
|
||||
@@ -168,5 +188,9 @@ export async function registerExecApprovalRequestForHost(params: {
|
||||
agentId: params.agentId,
|
||||
resolvedPath: params.resolvedPath,
|
||||
sessionKey: params.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ export type ProcessGatewayAllowlistParams = {
|
||||
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
scopeKey?: string;
|
||||
warnings: string[];
|
||||
notifySessionKey?: string;
|
||||
@@ -159,6 +163,10 @@ export async function processGatewayAllowlist(
|
||||
agentId: params.agentId,
|
||||
resolvedPath,
|
||||
sessionKey: params.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
});
|
||||
expiresAtMs = registration.expiresAtMs;
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
@@ -35,6 +35,10 @@ export type ExecuteNodeHostCommandParams = {
|
||||
requestedNode?: string;
|
||||
boundNode?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
agentId?: string;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
@@ -202,6 +206,10 @@ export async function executeNodeHostCommand(
|
||||
ask: hostAsk,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
});
|
||||
expiresAtMs = registration.expiresAtMs;
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
@@ -21,6 +21,9 @@ export type ExecToolDefaults = {
|
||||
scopeKey?: string;
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
accountId?: string;
|
||||
notifyOnExit?: boolean;
|
||||
notifyOnExitEmptySuccess?: boolean;
|
||||
cwd?: string;
|
||||
|
||||
@@ -407,6 +407,10 @@ export function createExecTool(
|
||||
requestedNode: params.node?.trim(),
|
||||
boundNode: defaults?.node?.trim(),
|
||||
sessionKey: defaults?.sessionKey,
|
||||
turnSourceChannel: defaults?.messageProvider,
|
||||
turnSourceTo: defaults?.currentChannelId,
|
||||
turnSourceAccountId: defaults?.accountId,
|
||||
turnSourceThreadId: defaults?.currentThreadTs,
|
||||
agentId,
|
||||
security,
|
||||
ask,
|
||||
@@ -433,6 +437,10 @@ export function createExecTool(
|
||||
safeBinProfiles,
|
||||
agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
turnSourceChannel: defaults?.messageProvider,
|
||||
turnSourceTo: defaults?.currentChannelId,
|
||||
turnSourceAccountId: defaults?.accountId,
|
||||
turnSourceThreadId: defaults?.currentThreadTs,
|
||||
scopeKey: defaults?.scopeKey,
|
||||
warnings,
|
||||
notifySessionKey,
|
||||
|
||||
@@ -116,6 +116,10 @@ export function createOpenClawTools(options?: {
|
||||
createCanvasTool({ config: options?.config }),
|
||||
createNodesTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentChannel: options?.agentChannel,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
config: options?.config,
|
||||
}),
|
||||
createCronTool({
|
||||
|
||||
@@ -401,6 +401,9 @@ export function createOpenClawCodingTools(options?: {
|
||||
scopeKey,
|
||||
sessionKey: options?.sessionKey,
|
||||
messageProvider: options?.messageProvider,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
accountId: options?.agentAccountId,
|
||||
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
|
||||
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
|
||||
approvalRunningNoticeMs:
|
||||
|
||||
@@ -20,6 +20,7 @@ import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { formatExecCommand } from "../../infra/system-run-command.js";
|
||||
import { imageMimeFromFormat } from "../../media/mime.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
|
||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||
@@ -128,9 +129,17 @@ const NodesToolSchema = Type.Object({
|
||||
|
||||
export function createNodesTool(options?: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string | number;
|
||||
config?: OpenClawConfig;
|
||||
}): AnyAgentTool {
|
||||
const sessionKey = options?.agentSessionKey?.trim() || undefined;
|
||||
const turnSourceChannel = options?.agentChannel?.trim() || undefined;
|
||||
const turnSourceTo = options?.currentChannelId?.trim() || undefined;
|
||||
const turnSourceAccountId = options?.agentAccountId?.trim() || undefined;
|
||||
const turnSourceThreadId = options?.currentThreadTs;
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
@@ -512,6 +521,10 @@ export function createNodesTool(options?: {
|
||||
host: "node",
|
||||
agentId,
|
||||
sessionKey,
|
||||
turnSourceChannel,
|
||||
turnSourceTo,
|
||||
turnSourceAccountId,
|
||||
turnSourceThreadId,
|
||||
timeoutMs: APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -98,6 +98,10 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
turnSourceChannel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
turnSourceTo: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
turnSourceAccountId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Null()])),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
twoPhase: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
|
||||
@@ -88,6 +88,19 @@ function isCanvasPath(pathname: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function decodePathnameOnce(pathname: string): string {
|
||||
try {
|
||||
return decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return pathname;
|
||||
}
|
||||
}
|
||||
|
||||
function isProtectedPluginChannelPath(pathname: string): boolean {
|
||||
const normalized = decodePathnameOnce(pathname).toLowerCase();
|
||||
return normalized === "/api/channels" || normalized.startsWith("/api/channels/");
|
||||
}
|
||||
|
||||
function isNodeWsClient(client: GatewayWsClient): boolean {
|
||||
if (client.connect.role === "node") {
|
||||
return true;
|
||||
@@ -493,7 +506,7 @@ export function createGatewayHttpServer(opts: {
|
||||
// Channel HTTP endpoints are gateway-auth protected by default.
|
||||
// Non-channel plugin routes remain plugin-owned and must enforce
|
||||
// their own auth when exposing sensitive functionality.
|
||||
if (requestPath === "/api/channels" || requestPath.startsWith("/api/channels/")) {
|
||||
if (isProtectedPluginChannelPath(requestPath)) {
|
||||
const token = getBearerToken(req);
|
||||
const authResult = await authorizeHttpGatewayConnect({
|
||||
auth: resolvedAuth,
|
||||
|
||||
@@ -52,6 +52,10 @@ export function createExecApprovalHandlers(
|
||||
agentId?: string;
|
||||
resolvedPath?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
timeoutMs?: number;
|
||||
twoPhase?: boolean;
|
||||
};
|
||||
@@ -91,6 +95,12 @@ export function createExecApprovalHandlers(
|
||||
agentId: p.agentId ?? null,
|
||||
resolvedPath: p.resolvedPath ?? null,
|
||||
sessionKey: p.sessionKey ?? null,
|
||||
turnSourceChannel:
|
||||
typeof p.turnSourceChannel === "string" ? p.turnSourceChannel.trim() || null : null,
|
||||
turnSourceTo: typeof p.turnSourceTo === "string" ? p.turnSourceTo.trim() || null : null,
|
||||
turnSourceAccountId:
|
||||
typeof p.turnSourceAccountId === "string" ? p.turnSourceAccountId.trim() || null : null,
|
||||
turnSourceThreadId: p.turnSourceThreadId ?? null,
|
||||
};
|
||||
const record = manager.create(request, timeoutMs, explicitId);
|
||||
record.requestedByConnId = client?.connId ?? null;
|
||||
|
||||
@@ -493,6 +493,56 @@ describe("exec approval handlers", () => {
|
||||
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
});
|
||||
|
||||
it("forwards turn-source metadata to exec approval forwarding", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = new ExecApprovalManager();
|
||||
const forwarder = {
|
||||
handleRequested: vi.fn(async () => false),
|
||||
handleResolved: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const handlers = createExecApprovalHandlers(manager, { forwarder });
|
||||
const respond = vi.fn();
|
||||
const context = {
|
||||
broadcast: (_event: string, _payload: unknown) => {},
|
||||
hasExecApprovalClients: () => false,
|
||||
};
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
timeoutMs: 60_000,
|
||||
turnSourceChannel: "whatsapp",
|
||||
turnSourceTo: "+15555550123",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "1739201675.123",
|
||||
},
|
||||
});
|
||||
for (let idx = 0; idx < 20; idx += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
request: expect.objectContaining({
|
||||
turnSourceChannel: "whatsapp",
|
||||
turnSourceTo: "+15555550123",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "1739201675.123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await requestPromise;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("expires immediately when no approver clients and no forwarding targets", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -242,6 +242,78 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requires gateway auth for canonicalized /api/channels variants", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
await withTempConfig({
|
||||
cfg: { gateway: { trustedProxies: [] } },
|
||||
prefix: "openclaw-plugin-http-auth-canonicalized-test-",
|
||||
run: async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const canonicalPath = decodeURIComponent(pathname).toLowerCase();
|
||||
if (canonicalPath === "/api/channels/nostr/default/profile") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const server = createGatewayHttpServer({
|
||||
canvasHost: null,
|
||||
clients: new Set(),
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: "/__control__",
|
||||
openAiChatCompletionsEnabled: false,
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
handlePluginRequest,
|
||||
resolvedAuth,
|
||||
});
|
||||
|
||||
const unauthenticatedCaseVariant = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/API/channels/nostr/default/profile" }),
|
||||
unauthenticatedCaseVariant.res,
|
||||
);
|
||||
expect(unauthenticatedCaseVariant.res.statusCode).toBe(401);
|
||||
expect(unauthenticatedCaseVariant.getBody()).toContain("Unauthorized");
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
|
||||
const unauthenticatedEncodedSlash = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" }),
|
||||
unauthenticatedEncodedSlash.res,
|
||||
);
|
||||
expect(unauthenticatedEncodedSlash.res.statusCode).toBe(401);
|
||||
expect(unauthenticatedEncodedSlash.getBody()).toContain("Unauthorized");
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
|
||||
const authenticatedCaseVariant = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: "/API/channels/nostr/default/profile",
|
||||
authorization: "Bearer test-token",
|
||||
}),
|
||||
authenticatedCaseVariant.res,
|
||||
);
|
||||
expect(authenticatedCaseVariant.res.statusCode).toBe(200);
|
||||
expect(authenticatedCaseVariant.getBody()).toContain('"route":"channel-canonicalized"');
|
||||
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["0.0.0.0", "::"])(
|
||||
"returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s",
|
||||
async (bindHost) => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
|
||||
@@ -40,14 +43,17 @@ function createForwarder(params: {
|
||||
resolveSessionTarget?: () => { channel: string; to: string } | null;
|
||||
}) {
|
||||
const deliver = params.deliver ?? vi.fn().mockResolvedValue([]);
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
const deps: NonNullable<Parameters<typeof createExecApprovalForwarder>[0]> = {
|
||||
getConfig: () => params.cfg,
|
||||
deliver: deliver as unknown as NonNullable<
|
||||
NonNullable<Parameters<typeof createExecApprovalForwarder>[0]>["deliver"]
|
||||
>,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: params.resolveSessionTarget ?? (() => null),
|
||||
});
|
||||
};
|
||||
if (params.resolveSessionTarget !== undefined) {
|
||||
deps.resolveSessionTarget = params.resolveSessionTarget;
|
||||
}
|
||||
const forwarder = createExecApprovalForwarder(deps);
|
||||
return { deliver, forwarder };
|
||||
}
|
||||
|
||||
@@ -212,6 +218,58 @@ describe("exec approval forwarder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers turn-source routing over stale session last route", async () => {
|
||||
vi.useFakeTimers();
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approval-forwarder-test-"));
|
||||
try {
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:main": {
|
||||
updatedAt: 1,
|
||||
channel: "slack",
|
||||
to: "U1",
|
||||
lastChannel: "slack",
|
||||
lastTo: "U1",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { deliver, forwarder } = createForwarder({ cfg });
|
||||
await expect(
|
||||
forwarder.handleRequested({
|
||||
...baseRequest,
|
||||
request: {
|
||||
...baseRequest.request,
|
||||
turnSourceChannel: "whatsapp",
|
||||
turnSourceTo: "+15555550123",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "1739201675.123",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(deliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "whatsapp",
|
||||
to: "+15555550123",
|
||||
accountId: "work",
|
||||
threadId: "1739201675.123",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can forward resolved notices without pending cache when request payload is present", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
|
||||
@@ -8,7 +8,11 @@ import type {
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { compileSafeRegex } from "../security/safe-regex.js";
|
||||
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
type DeliverableMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
import type {
|
||||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
@@ -209,6 +213,11 @@ function buildExpiredMessage(request: ExecApprovalRequest) {
|
||||
return `⏱️ Exec approval expired. ID: ${request.id}`;
|
||||
}
|
||||
|
||||
function normalizeTurnSourceChannel(value?: string | null): DeliverableMessageChannel | undefined {
|
||||
const normalized = value ? normalizeMessageChannel(value) : undefined;
|
||||
return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function defaultResolveSessionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
@@ -225,7 +234,14 @@ function defaultResolveSessionTarget(params: {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" });
|
||||
const target = resolveSessionDeliveryTarget({
|
||||
entry,
|
||||
requestedChannel: "last",
|
||||
turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel),
|
||||
turnSourceTo: params.request.request.turnSourceTo?.trim() || undefined,
|
||||
turnSourceAccountId: params.request.request.turnSourceAccountId?.trim() || undefined,
|
||||
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
|
||||
});
|
||||
if (!target.channel || !target.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export type ExecApprovalRequestPayload = {
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
turnSourceTo?: string | null;
|
||||
turnSourceAccountId?: string | null;
|
||||
turnSourceThreadId?: string | number | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
|
||||
Reference in New Issue
Block a user