fix: refine Slack user token routing (#981) (thanks @jalehman)

This commit is contained in:
Peter Steinberger 2026-01-16 00:11:12 +00:00
parent bb568b2a45
commit dcfde2822a
10 changed files with 164 additions and 248 deletions

View File

@ -2,6 +2,7 @@
## 2026.1.15 (unreleased)
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.

@ -1 +1 @@
Subproject commit c1243a7978b71137060a82aa4a451e1720e36aff
Subproject commit 95ad7532c15b6b4e67cdf2c8c5dea399c8483fe1

View File

@ -33,6 +33,7 @@ Minimal config:
- `app_mention`
- `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename`
- `pin_added`, `pin_removed`
6) Invite the bot to channels you want it to read.
@ -112,6 +113,21 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
## Config writes
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
This happens when:
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
Disable with:
```json5
{
channels: { slack: { configWrites: false } }
}
```
## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
@ -196,6 +212,7 @@ user scopes if you plan to configure a user token.
"reaction_removed",
"member_joined_channel",
"member_left_channel",
"channel_id_changed",
"channel_rename",
"pin_added",
"pin_removed"
@ -326,6 +343,11 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Thread session isolation
Slack thread sessions are isolated by default. Configure with:
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
@ -390,4 +412,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
- Attachments are downloaded to the media store when permitted and under the size limit.

View File

@ -1052,6 +1052,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
userToken: "xoxp-...", // optional (read-only by default)
userTokenReadOnly: true, // default: true
dm: {
enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled
@ -1102,6 +1104,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Optional user tokens (`xoxp-...`) are configured in the config file (no env var support). Reads prefer the user token when present; writes use the bot token unless you set `channels.slack.userTokenReadOnly: false` and no bot token is available.
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.

View File

@ -373,6 +373,28 @@ describe("handleSlackAction", () => {
expect(opts?.token).toBe("xoxp-1");
});
it("uses user token for pin reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
listSlackPins.mockClear();
listSlackPins.mockResolvedValueOnce([]);
await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const [, opts] = listSlackPins.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("uses user token for member info reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
getSlackMemberInfo.mockClear();
getSlackMemberInfo.mockResolvedValueOnce({});
await handleSlackAction({ action: "memberInfo", userId: "U1" }, cfg);
const [, opts] = getSlackMemberInfo.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("falls back to bot token for reads when user token missing", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1" } },
@ -405,4 +427,16 @@ describe("handleSlackAction", () => {
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("prefers bot token for writes when both tokens are available", async () => {
const cfg = {
channels: {
slack: { botToken: "xoxb-1", userToken: "xoxp-1", userTokenReadOnly: false },
},
} as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBeUndefined();
});
});

View File

@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import {
deleteSlackMessage,
editSlackMessage,
@ -81,20 +82,14 @@ export async function handleSlackAction(
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
const userToken = account.config.userToken?.trim() || undefined;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
// Choose the most appropriate token for Slack read/write operations.
const getTokenForOperation = (operation: "read" | "write") => {
if (operation === "read") return userToken ?? botToken;
if (!allowUserWrites) return botToken;
return botToken ?? userToken;
};
const buildActionOpts = (operation: "read" | "write") => {
const token = getTokenForOperation(operation);
const tokenOverride = token && token !== botToken ? token : undefined;
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation,
});
if (!accountId && !tokenOverride) return undefined;
return {
...(accountId ? { accountId } : {}),
@ -260,7 +255,7 @@ export async function handleSlackAction(
}
return jsonResult({ ok: true });
}
const pins = writeOpts
const pins = readOpts
? await listSlackPins(channelId, readOpts)
: await listSlackPins(channelId);
const normalizedPins = pins.map((pin) => {
@ -280,7 +275,7 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const info = writeOpts
const info = readOpts
? await getSlackMemberInfo(userId, readOpts)
: await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });

View File

@ -1,4 +1,6 @@
import { resolveSlackAccount } from "../../../slack/accounts.js";
import { sendMessageSlack } from "../../../slack/send.js";
import { resolveSlackTokenOverride } from "../../../slack/token.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const slackOutbound: ChannelOutboundAdapter = {
@ -15,20 +17,36 @@ export const slackOutbound: ChannelOutboundAdapter = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},

View File

@ -1,9 +1,5 @@
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import { loadConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import {
listEnabledSlackAccounts,
listSlackAccountIds,
type ResolvedSlackAccount,
resolveDefaultSlackAccountId,
@ -11,6 +7,7 @@ import {
} from "../../slack/accounts.js";
import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import { getChatChannelMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
@ -25,23 +22,11 @@ import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
import { createSlackActions } from "./slack.actions.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("slack");
// Select the appropriate Slack token for read/write operations.
function getTokenForOperation(
account: ResolvedSlackAccount,
operation: "read" | "write",
): string | undefined {
const userToken = account.config.userToken?.trim() || undefined;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
if (operation === "read") return userToken ?? botToken;
if (!allowUserWrites) return botToken;
return botToken ?? userToken;
}
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
id: "slack",
meta: {
@ -51,22 +36,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
pairing: {
idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ id }) => {
const cfg = loadConfig();
const account = resolveSlackAccount({
cfg,
accountId: DEFAULT_ACCOUNT_ID,
notifyApproval: async ({ cfg, id }) => {
const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
if (tokenOverride) {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
token: tokenOverride,
});
} else {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
}
await sendMessageSlack(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
tokenOverride ? { token: tokenOverride } : undefined,
);
},
},
capabilities: {
@ -169,197 +151,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
},
actions: {
listActions: ({ cfg }) => {
const accounts = listEnabledSlackAccounts(cfg).filter(
(account) => account.botTokenSource !== "none",
);
if (accounts.length === 0) return [];
const isActionEnabled = (key: string, defaultValue = true) => {
for (const account of accounts) {
const gate = createActionGate(
(account.actions ?? cfg.channels?.slack?.actions) as Record<
string,
boolean | undefined
>,
);
if (gate(key, defaultValue)) return true;
}
return false;
};
const actions = new Set<ChannelMessageActionName>(["send"]);
if (isActionEnabled("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (isActionEnabled("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (isActionEnabled("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (isActionEnabled("memberInfo")) actions.add("member-info");
if (isActionEnabled("emojiList")) actions.add("emoji-list");
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const resolveChannelId = () =>
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo");
return await handleSlackAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
toolContext,
);
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "message", { required: true });
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await handleSlackAction(
{
action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
if (action === "emoji-list") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider ${meta.id}.`);
},
},
actions: createSlackActions(meta.id),
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
@ -448,9 +240,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined,
@ -461,9 +256,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,

View File

@ -1,7 +1,7 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { SlackAccountConfig } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
export type SlackTokenSource = "env" | "config" | "none";
@ -11,6 +11,7 @@ export type ResolvedSlackAccount = {
name?: string;
botToken?: string;
appToken?: string;
userToken?: string;
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
config: SlackAccountConfig;
@ -75,6 +76,7 @@ export function resolveSlackAccount(params: {
const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
const configBot = resolveSlackBotToken(merged.botToken);
const configApp = resolveSlackAppToken(merged.appToken);
const configUser = resolveSlackUserToken(merged.userToken);
const botToken = configBot ?? envBot;
const appToken = configApp ?? envApp;
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
@ -86,6 +88,7 @@ export function resolveSlackAccount(params: {
name: merged.name?.trim() || undefined,
botToken,
appToken,
userToken: configUser,
botTokenSource,
appTokenSource,
config: merged,

View File

@ -10,3 +10,44 @@ export function resolveSlackBotToken(raw?: string): string | undefined {
export function resolveSlackAppToken(raw?: string): string | undefined {
return normalizeSlackToken(raw);
}
export function resolveSlackUserToken(raw?: string): string | undefined {
return normalizeSlackToken(raw);
}
export type SlackTokenOperation = "read" | "write";
export type SlackTokenSelection = {
botToken?: string;
userToken?: string;
userTokenReadOnly?: boolean;
operation: SlackTokenOperation;
};
function selectSlackTokenNormalized(params: SlackTokenSelection): string | undefined {
const allowUserWrites = params.userTokenReadOnly === false;
if (params.operation === "read") return params.userToken ?? params.botToken;
if (!allowUserWrites) return params.botToken;
return params.botToken ?? params.userToken;
}
export function selectSlackToken(params: SlackTokenSelection): string | undefined {
const botToken = resolveSlackBotToken(params.botToken);
const userToken = resolveSlackUserToken(params.userToken);
return selectSlackTokenNormalized({
...params,
botToken,
userToken,
});
}
export function resolveSlackTokenOverride(params: SlackTokenSelection): string | undefined {
const botToken = resolveSlackBotToken(params.botToken);
const userToken = resolveSlackUserToken(params.userToken);
const token = selectSlackTokenNormalized({
...params,
botToken,
userToken,
});
return token && token !== botToken ? token : undefined;
}