diff --git a/CHANGELOG.md b/CHANGELOG.md index 47081ae8eac..e03932f15e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/download-file scope binding: require channel-targeted `download-file` actions and enforce channel/thread share checks before resolving Slack private download URLs, reducing cross-channel/thread attachment fetch surface. - Agents/Model fallback: classify additional network transport errors (`ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETRESET`, `EAI_AGAIN`) as failover-worthy so fallback chains advance when primary providers are unreachable. Landed from contributor PR #19077 by @ayanesakura. Thanks @ayanesakura. - Agents/Copilot token refresh: refresh GitHub Copilot runtime API tokens after auth-expiry failures and re-run with the renewed token so long-running embedded/subagent turns do not fail on mid-session 401 expiry. Landed from contributor PR #8805 by @Arthur742Ramos. Thanks @Arthur742Ramos. - Discord/Allowlist diagnostics: add debug logs for guild/channel allowlist drops so operators can quickly identify ignored inbound messages and required allowlist entries. Landed from contributor PR #30966 by @haosenwang1018. Thanks @haosenwang1018. diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index 8a57602f58e..e92988e7f78 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -202,12 +202,13 @@ describe("handleSlackAction", () => { { action: "downloadFile", fileId: "F123", + channelId: "C1", }, slackConfig(), ); expect(downloadSlackFile).toHaveBeenCalledWith( "F123", - expect.objectContaining({ maxBytes: 20 * 1024 * 1024 }), + expect.objectContaining({ channelId: "C1", maxBytes: 20 * 1024 * 1024 }), ); expect(result).toEqual( expect.objectContaining({ @@ -243,6 +244,18 @@ describe("handleSlackAction", () => { ); }); + it("requires a channel target for downloadFile", async () => { + await expect( + handleSlackAction( + { + action: "downloadFile", + fileId: "F123", + }, + slackConfig(), + ), + ).rejects.toThrow(/to/i); + }); + it.each([ { name: "JSON blocks", diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 20a491c350d..6b3df9840a4 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -290,8 +290,9 @@ export async function handleSlackAction( } case "downloadFile": { const fileId = readStringParam(params, "fileId", { required: true }); - const channelTarget = readStringParam(params, "channelId") ?? readStringParam(params, "to"); - const channelId = channelTarget ? resolveSlackChannelId(channelTarget) : undefined; + const channelTarget = + readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); + const channelId = resolveSlackChannelId(channelTarget); const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo"); const maxBytes = account.config?.mediaMaxMb ? account.config.mediaMaxMb * 1024 * 1024 diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 641cc362077..b7d963e3946 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -55,7 +55,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { diff --git a/src/plugin-sdk/slack-message-actions.test.ts b/src/plugin-sdk/slack-message-actions.test.ts index 109b825fab9..2fb5afcdf24 100644 --- a/src/plugin-sdk/slack-message-actions.test.ts +++ b/src/plugin-sdk/slack-message-actions.test.ts @@ -63,4 +63,26 @@ describe("handleSlackMessageAction", () => { expect.any(Object), ); }); + + it("requires a target channel for download-file", async () => { + const invoke = vi.fn(async (action: Record) => ({ + ok: true, + content: action, + })); + + await expect( + handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "download-file", + cfg: {}, + params: { + fileId: "F-no-target", + }, + } as never, + invoke: invoke as never, + }), + ).rejects.toThrow(/to/i); + expect(invoke).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugin-sdk/slack-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts index d9e0fa333a5..ec4f8273b7d 100644 --- a/src/plugin-sdk/slack-message-actions.ts +++ b/src/plugin-sdk/slack-message-actions.ts @@ -178,15 +178,14 @@ export async function handleSlackMessageAction(params: { if (action === "download-file") { const fileId = readStringParam(actionParams, "fileId", { required: true }); - const channelId = - readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to"); + const channelId = resolveChannelId(); const threadId = readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo"); return await invoke( { action: "downloadFile", fileId, - channelId: channelId ?? undefined, + channelId, threadId: threadId ?? undefined, accountId, }, diff --git a/src/slack/actions.download-file.test.ts b/src/slack/actions.download-file.test.ts index b7afe84b149..6a74ca0d394 100644 --- a/src/slack/actions.download-file.test.ts +++ b/src/slack/actions.download-file.test.ts @@ -39,6 +39,7 @@ describe("downloadSlackFile", () => { client, token: "xoxb-test", maxBytes: 1024, + channelId: "C123", }); expect(result).toBeNull(); @@ -67,6 +68,7 @@ describe("downloadSlackFile", () => { client, token: "xoxb-test", maxBytes: 1024, + channelId: "C123", }); expect(client.files.info).toHaveBeenCalledWith({ file: "F123" }); diff --git a/src/slack/actions.ts b/src/slack/actions.ts index d2e57959b0e..a8e4385bfc3 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -372,12 +372,12 @@ function collectSlackThreadShares( function hasSlackScopeMismatch(params: { file: SlackFileInfoSummary; - channelId?: string; + channelId: string; threadId?: string; }): boolean { const channelId = normalizeSlackScopeValue(params.channelId); if (!channelId) { - return false; + return true; } const threadId = normalizeSlackScopeValue(params.threadId); @@ -410,7 +410,7 @@ function hasSlackScopeMismatch(params: { */ export async function downloadSlackFile( fileId: string, - opts: SlackActionClientOpts & { maxBytes: number; channelId?: string; threadId?: string }, + opts: SlackActionClientOpts & { maxBytes: number; channelId: string; threadId?: string }, ): Promise { const token = resolveToken(opts.token, opts.accountId); const client = await getClient(opts);