diff --git a/CHANGELOG.md b/CHANGELOG.md index 629cb667604..8533c2a8ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/command/delivery.test.ts b/src/agents/command/delivery.test.ts index 3dad9c69658..2e4e9383e2c 100644 --- a/src/agents/command/delivery.test.ts +++ b/src/agents/command/delivery.test.ts @@ -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[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 => ({ + ...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(), diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index dd148209f2b..46ab9ec8d24 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -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 { + 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( diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts index 2f59a8b37e4..6c978b1768e 100644 --- a/src/auto-reply/reply/reply-media-paths.ts +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -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 { - 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,