mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(cli): normalize reply-media paths for agent --deliver (#68516)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user