Uh oh!
There was an error while loading. Please reload this page.
Slack channel with coherent runtime and access guardrails - #403
Draft
alex-clickhouse wants to merge 18 commits into
Draft
Slack channel with coherent runtime and access guardrails#403alex-clickhouse wants to merge 18 commits into
alex-clickhouse wants to merge 18 commits into
Conversation
alex-clickhouseforce-pushed
the
alex/slack-channel
branch
6 times, most recently
from
August 23, 2026 17:52
4589ac8 to
2a93b8bComparealex-clickhouse
changed the base branch from
main
to
alex/slack-channel-foundationsAugust 23, 2026 17:52
This was referenced Aug 23, 2026
alex-clickhouseforce-pushed
the
alex/slack-channel
branch
from
August 25, 2026 10:59
85d757b to
442262aComparealex-clickhouseforce-pushed
the
alex/slack-channel
branch
2 times, most recently
from
August 25, 2026 14:43
4ea7a12 to
ca702e1CompareImplement Socket Mode transport, fail-closed sender and conversation policies, per-thread sessions, streaming, file handling, reactions, slash commands, reconnect supervision, configuration, and daemon lifecycle. Cover the channel with a local Web API/Socket Mode stand-in plus unit and integration tests.
Keep the stronger behavioral coverage while dropping duplicate and misleading cases. Consolidate long-message splitting and losslessness into one assertion path.
Rotate the Web API and Socket Mode clients as one active credential pair during config reload. Preflight both tokens, prevent overlapping sockets, bound connection attempts, and restore the prior transport on failure.
A button press was authorized only when the payload carried a channel, so a block_actions envelope without one reached stop_session, the star toggle, and the notification answer route with no policy check at all. Slack omits the channel for interactions on a view surface. Refuse a press that names no conversation, then authorize every press. Allow rules matched profile.display_name and profile.real_name, which a member edits at will, so any member could rename themselves onto allow_users and take a full agent turn. Split the resolved aliases: a grant may rest on the member ID, the handle, or the verified email, while a deny rule keeps matching the self-set names too. A grant that matches only a self-set name is refused with a reason that names the fix.
A shared-channel reply continues an owned thread with no further mention, so two agents in one channel answered each other without end once a person mentioned both. With a channel-only grant, which docs/config.md offers for shared channels, nothing stopped the exchange. bot_id alone cannot decide it: a person posting through an integration keeps their own user id and gains the app's bot_id, and those messages are meant to arrive. Ask users.info whether the sender is a bot user, cache the verdict beside the resolved names, and treat an unresolved sender next to a bot_id as an app. A message with no bot_id costs no lookup.
A Slack ts is unique inside one conversation, not across the workspace, but the reaction cache was keyed on ts alone. A reaction in one channel could therefore find a cached message from another, and the turn was authorized against the channel the reaction came from while being routed into the session of the channel that held the cache entry. Key on the conversation and the ts together. The stored target still keeps the thread, which the key does not.
Three faults in one owner, fixed together because they share its structure. shutdown() left no mark, so the next reconcile took the enable path and built a fresh channel, socket and watchdog. The lifespan stops Slack before it stops the sync loop, and that loop is given a bounded wait to finish the cycle it is in, whose git phase runs in a worker thread past cancellation. A reload therefore arrives after shutdown and reopened a socket that outlived the process. Mark the runtime closed and report the reload as such. The watchdog held the lifecycle lock while rebuilding the socket, which waits on a close and a connect. While Slack was unreachable that blocked every config reload and the shutdown for up to the sum of those bounds. Hold the lock for the decision, repair outside it, and refuse a rebuild once the channel is stopping. Repair and rotation still serialize on the channel's transport lock. Redaction read the active generation after stopping had already cleared it, so disabling Slack and removing its tokens in one edit put the live token verbatim into the summary that reload_all returns over HTTP. Capture the secrets before the transition and redact every failure leaving reconcile against them.
Seven outbound methods checked only that a Web client existed. That attribute is also set while the channel starts, and again while a rotation validates the next credential pair before connecting its socket, so a caller could reach Slack through a client whose generation is not the one serving events. send() had the worse shape: it returned quietly, and StreamAdapter reads a quiet return as a delivered reply and deletes the streaming placeholder, so the user was left with neither the placeholder nor the answer. It now refuses, which is what its own docstring already promised, and the adapter takes its recovery branch. Post paths refuse. Best-effort paths, which already swallow their own failures, stay quiet and now agree with is_available.
Slack rate limits chat.update per conversation, but the interval was held per StreamAdapter and there is one of those per inbound message. Several threads streaming in one channel therefore went over the limit together. The SDK answers a 429 by sleeping inside the request, and the streaming listener is awaited from the agent's token loop, so those sleeps stalled the run. Two changes. The adapter stamps the interval before the attempt, so a failed edit still holds it instead of letting every later token retry at once. And an edit the caller can afford to lose is marked throttle=True, which lets Slack shed it against a per-conversation clock. A final or recovery edit leaves the flag unset and always goes out, because that path is the only thing between a failed send and a lost reply. Raise the failed-edit log out of debug; the interval bounds its rate.
alex-clickhouseforce-pushed
the
alex/slack-channel
branch
from
August 26, 2026 11:59
ca702e1 to
faa53abCompare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 2 of the Slack channel stack. Adds a Slack adapter over Socket Mode: it
receives events on an outbound WebSocket, so it needs no public URL. Access is
refused by default, each thread in a shared channel is its own session, and one
object owns every start, stop, and reload.
Stack
How a message becomes a session
session keyed
slack:<channel>:<thread_ts>. Replies in it continue thesession with no further mention.
the bot in it.
thread, so two people can run separate tasks in one channel without their
turns mixing.
slack:<dm_id>.cannot answer each other without end. A person posting through an
integration still reaches the agent, because they keep their own user id.
slack.reply_in_threadis removed. A slash command payload carries nothread id, so in a shared channel a command either offers a picker of that
channel's live threads or refuses and asks for a DM.
Who may talk to it
The agent can run tools and shell commands, so the gate is closed until it is
opened on purpose.
allow_users, noallow_channels, and no DM grant, every messageis refused. Startup logs a warning saying so, because an unconfigured
policy is silent otherwise.
clear a deny list.
a display name or full name, because a member edits those and could
otherwise rename themselves onto the allow list. Deny rules do match those
names, since refusing on more names than a grant may rest on is safe. The
matching itself lives in Shared chat-channel foundations #404; this PR decides which Slack aliases fall on
which side.
commands, and button presses.
allow_direct_messages: trueas well as a sender grant.Starting, stopping, and reloading
Slack hands each event to exactly one of an app's open Socket Mode
connections. Two open connections therefore lose events rather than duplicate
them, and most of the rules below exist to keep that from happening.
SlackRuntimeis the only thing that starts, stops, reconnects, orreconfigures the channel. It holds one lock, so those operations run one at a
time.
A channel that is still starting can receive but cannot send. It is
registered with the router as soon as it exists, so an event arriving during
startup has somewhere to go. Anything asking whether it can send through
Slack gets no until the channel is fully running. Without that split, a
notification could be recorded as delivered through a channel with no working
connection.
Changing tokens keeps one connection at a time. New tokens are validated
against Slack first, which needs no connection. Only then is the old socket
closed, and only after that does the new one connect. If the new tokens fail
to connect, the previous connection is restored. If that restore also fails,
the channel stops rather than stay up with credentials nobody can name.
Tokens for a different workspace need a restart. Session keys and cached
ids belong to the workspace that issued them, so a live reload refuses the
change and reports why.
A config change lands whole or not at all. The running channel reads one
snapshot, and access rules, enabled commands, workspace identity, and tokens
all advance together after a successful reload. A failed token change keeps
the previous snapshot. Otherwise a message could be checked against new access
rules and answered with old credentials.
Shutdown is final. The daemon stops Slack before it stops the workspace
sync loop, and that loop gets a bounded wait to finish the cycle it is in,
which ends in a config reload. That reload does not bring Slack back.
Repairing a dropped socket blocks nothing else. The watchdog takes the
lifecycle lock only to decide whether a reconnect is needed, then reconnects
outside it. Holding the lock across a reconnect made every config reload and
the shutdown itself wait on an unreachable Slack.
Failure messages carry no tokens. Both the tokens being replaced and the
ones replacing them are removed before a message reaches a log or an HTTP
response.
Generic reload code stays generic.
config_reload.pyasks the runtime toreconcile and holds no Slack token, construction, or drain logic.
Where the code lives
slack_access.pyslack_presentation.pyslack.pyslack_runtime.pyOther behavior worth knowing
anything unacknowledged after three seconds and an agent turn takes longer.
Repeat deliveries of one event collapse into a single run.
work with a timeout before closing the socket.
because that is how Slack meters
chat.update. A failed edit still holdsthe interval, so a rate limit cannot turn into a retry loop inside the
agent's token loop.