fix(cli): normalize reply-media paths for agent --deliver (#68516)

This commit is contained in:
Frank Yang
2026-04-18 20:05:41 +08:00
committed by GitHub
parent 25ce5a5822
commit 442deb0816
4 changed files with 169 additions and 4 deletions

View File

@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
- Cron/message tool: keep cron-owned runs with `delivery.mode: "none"` on the normal message-tool path so they can still send explicit messages, create threads, and route conditionally when no runner-owned delivery target is active. (#68482) Thanks @obviyus.
- Agents/failover: avoid treating bare leading `402 ...` prose as billing errors while still recognizing proxy subscription failures. (#45827) Thanks @junyuc25.
- Config/$schema: preserve root-authored `$schema` during partial config rewrites without injecting include-only schema URLs into the root config. (#47322) Thanks @EfeDurmaz16.
- Agents/CLI delivery: run the same reply-media path normalizer the auto-reply flow uses before shipping `openclaw agent --deliver` payloads, so relative `MEDIA:./out/photo.png` tokens resolve against the agent workspace instead of being rejected downstream with `LocalMediaAccessError: Local media path is not under an allowed directory`. Thanks @frankekn.
## 2026.4.15

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -7,6 +8,24 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c
import { deliverAgentCommandResult, normalizeAgentCommandReplyPayloads } from "./delivery.js";
import type { AgentCommandOpts } from "./types.js";
const deliverOutboundPayloadsMock = vi.hoisted(() =>
vi.fn(async (..._args: unknown[]) => [] as unknown[]),
);
vi.mock("../../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: deliverOutboundPayloadsMock,
}));
const createReplyMediaPathNormalizerMock = vi.hoisted(() =>
vi.fn(
(..._args: unknown[]) =>
(payload: ReplyPayload) =>
Promise.resolve(payload),
),
);
vi.mock("../../auto-reply/reply/reply-media-paths.runtime.js", () => ({
createReplyMediaPathNormalizer: createReplyMediaPathNormalizerMock,
}));
type NormalizeParams = Parameters<typeof normalizeAgentCommandReplyPayloads>[0];
type RunResult = NormalizeParams["result"];
@@ -137,6 +156,94 @@ describe("normalizeAgentCommandReplyPayloads", () => {
expect(delivered.payloads).toMatchObject([{ text: "Options: on, off." }]);
});
it("normalizes reply-media paths before outbound delivery", async () => {
const runtime = { log: vi.fn(), error: vi.fn() };
const normalizerFn = vi.fn(
async (payload: ReplyPayload): Promise<ReplyPayload> => ({
...payload,
mediaUrl: "/tmp/agent-workspace/out/photo.png",
mediaUrls: ["/tmp/agent-workspace/out/photo.png"],
}),
);
createReplyMediaPathNormalizerMock.mockReturnValue(normalizerFn);
deliverOutboundPayloadsMock.mockResolvedValue([]);
await deliverAgentCommandResult({
cfg: {
agents: {
list: [{ id: "tester", workspace: "/tmp/agent-workspace" }],
},
} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "go",
deliver: true,
replyChannel: "slack",
replyTo: "#general",
} as AgentCommandOpts,
outboundSession: {
key: "agent:tester:slack:direct:alice",
agentId: "tester",
} as never,
sessionEntry: undefined,
payloads: [{ text: "here you go", mediaUrls: ["./out/photo.png"] }],
result: createResult(),
});
expect(createReplyMediaPathNormalizerMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:tester:slack:direct:alice",
agentId: "tester",
workspaceDir: "/tmp/agent-workspace",
messageProvider: "slack",
}),
);
expect(normalizerFn).toHaveBeenCalledWith(
expect.objectContaining({ mediaUrls: ["./out/photo.png"] }),
);
expect(deliverOutboundPayloadsMock).toHaveBeenCalledTimes(1);
const [firstCallArg] = deliverOutboundPayloadsMock.mock.calls[0] ?? [];
const deliverArgs = firstCallArg as { payloads: ReplyPayload[] } | undefined;
expect(deliverArgs?.payloads[0]).toMatchObject({
mediaUrls: ["/tmp/agent-workspace/out/photo.png"],
});
});
it("threads agentId into the normalizer when sessionKey is unresolved", async () => {
const runtime = { log: vi.fn(), error: vi.fn() };
createReplyMediaPathNormalizerMock.mockReturnValue(async (payload: ReplyPayload) => payload);
deliverOutboundPayloadsMock.mockResolvedValue([]);
await deliverAgentCommandResult({
cfg: {
agents: {
list: [{ id: "tester", workspace: "/tmp/agent-workspace" }],
},
} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "go",
deliver: true,
replyChannel: "slack",
replyTo: "#general",
} as AgentCommandOpts,
outboundSession: { agentId: "tester" } as never,
sessionEntry: undefined,
payloads: [{ text: "here you go", mediaUrls: ["./out/photo.png"] }],
result: createResult(),
});
expect(createReplyMediaPathNormalizerMock).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "tester",
sessionKey: undefined,
workspaceDir: "/tmp/agent-workspace",
}),
);
});
it("keeps LINE directive-only replies intact for local preview when delivery is disabled", async () => {
const runtime = {
log: vi.fn(),

View File

@@ -1,6 +1,8 @@
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope-config.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
import { createReplyMediaPathNormalizer } from "../../auto-reply/reply/reply-media-paths.runtime.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
@@ -67,6 +69,39 @@ function logNestedOutput(
}
}
async function normalizeReplyMediaPathsForDelivery(params: {
cfg: OpenClawConfig;
payloads: ReplyPayload[];
sessionKey?: string;
outboundSession: OutboundSessionContext | undefined;
deliveryChannel: string;
accountId?: string;
}): Promise<ReplyPayload[]> {
if (params.payloads.length === 0) {
return params.payloads;
}
const agentId =
params.outboundSession?.agentId ??
resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg });
const workspaceDir = agentId ? resolveAgentWorkspaceDir(params.cfg, agentId) : undefined;
if (!workspaceDir) {
return params.payloads;
}
const normalizeMediaPaths = createReplyMediaPathNormalizer({
cfg: params.cfg,
sessionKey: params.sessionKey,
agentId,
workspaceDir,
messageProvider: params.deliveryChannel,
accountId: params.accountId,
});
const result: ReplyPayload[] = [];
for (const payload of params.payloads) {
result.push(await normalizeMediaPaths(payload));
}
return result;
}
export function normalizeAgentCommandReplyPayloads(params: {
cfg: OpenClawConfig;
opts: AgentCommandOpts;
@@ -268,7 +303,23 @@ export async function deliverAgentCommandResult(params: {
accountId: resolvedAccountId,
applyChannelTransforms: deliver,
});
const outboundPayloadPlan = createOutboundPayloadPlan(normalizedReplyPayloads);
// Auto-reply-style media-path normalization must also run for the CLI
// `--deliver` path. Without it, relative `MEDIA:./out/photo.png` tokens
// reach the outbound loader unresolved and `assertLocalMediaAllowed` fails
// with "Local media path is not under an allowed directory". Mirrors the
// normalizer wiring in `src/auto-reply/reply/agent-runner.ts`.
const mediaNormalizedReplyPayloads =
deliver && !isInternalMessageChannel(deliveryChannel)
? await normalizeReplyMediaPathsForDelivery({
cfg,
payloads: normalizedReplyPayloads,
sessionKey: effectiveSessionKey,
outboundSession,
deliveryChannel,
accountId: resolvedAccountId,
})
: normalizedReplyPayloads;
const outboundPayloadPlan = createOutboundPayloadPlan(mediaNormalizedReplyPayloads);
const normalizedPayloads = projectOutboundPayloadPlanForJson(outboundPayloadPlan);
if (opts.json) {
runtime.log(

View File

@@ -82,6 +82,7 @@ function resolveReplyMediaMaxBytes(params: {
export function createReplyMediaPathNormalizer(params: {
cfg: OpenClawConfig;
sessionKey?: string;
agentId?: string;
workspaceDir: string;
messageProvider?: string;
accountId?: string;
@@ -93,9 +94,14 @@ export function createReplyMediaPathNormalizer(params: {
requesterSenderUsername?: string;
requesterSenderE164?: string;
}): (payload: ReplyPayload) => Promise<ReplyPayload> {
const agentId = params.sessionKey
? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg })
: undefined;
// Prefer an explicit agentId so callers without a resolved sessionKey (e.g.
// `openclaw agent --deliver` with `--reply-channel/--reply-to`) still get
// the stricter agent-scoped file-read policy applied during staging.
const agentId =
params.agentId ??
(params.sessionKey
? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg })
: undefined);
const maxBytes = resolveReplyMediaMaxBytes({
cfg: params.cfg,
channel: params.messageProvider,