Skip to content

fix(agentex): tell a user when SGP won't let their account hold a Slack config - #422

Merged
michael-chou359 merged 1 commit into
mainfrom
mc/slack-config-forbidden-notice
Sep 2, 2026
Merged

fix(agentex): tell a user when SGP won't let their account hold a Slack config#422
michael-chou359 merged 1 commit into
mainfrom
mc/slack-config-forbidden-notice

Conversation

@michael-chou359

@michael-chou359 michael-chou359 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The gap

SGP can refuse an account two ways, and both leave the person with no config of their own:

create:  POST /v5/agent_configs  -> 403 "action=create,
                                    legacy_roles=['admin','manager','editor']"
read:    GET  /v5/agent_configs  -> 403   (can't list the directory at all)

Either way every turn silently falls back to the shared bot — so both are now treated the same. Previously only the create half was recognised as permanent; a read refusal looked like a transient blip forever.

Such a user kept getting bot-run turns with nothing said, having just completed the link flow that told them their turns would run as them, and no way to learn why their own integrations never work.

This is not the same permission as running the agent

Worth stating, because it's a natural assumption that lacking config access means lacking access altogether. It doesn't:

run golden-agent       -> agentex authz (agent.execute), checked earlier
read/create a config   -> SGP resource permission

The users whose turns hung had tasks created and dispatched (status_reason = "Task created, forwarding to ACP server", named with their own sgp_user_id). _authorize runs before dispatch and posts "You're not authorized to run …" on failure — so authorization passed and only the config read failed. A viewer-role account is exactly the case with execute but not create.

What changed

A 403 is an authorization decision, so retrying changes nothing. Both paths raise _SgpAccessForbidden, and _run_turn posts a one-per-day ephemeral naming the role to ask for, then degrades to the bot as before.

Everything else still degrades quietly, because the remedies differ:

Remedy Told?
403 read or create role grant yes, once a day
401 re-link (credential rejected) no — the re-link prompt is driven elsewhere; logged separately so it's diagnosable
other 4xx/5xx, transport may work next turn no

The raise is contained where propagating would be wrong

  • selector resolution is best-effort and its caller falls back to the default config, so raising there would error a turn that could still be answered;
  • the post-create confirmation already has a working config, so a refused re-read keeps what was made rather than discarding it.

Both broad except Exception handlers re-raise the signal explicitly — either would otherwise swallow it and restore the silent behaviour.

The cooldown only counts if the notice was delivered

Claiming the cooldown first is deliberate — it stops two concurrent turns both posting. But committing it before attempting delivery meant a rejected ephemeral suppressed the message for the whole 24h window while the user was told nothing, which is the exact failure this PR exists to prevent.

Slack refusing an ephemeral isn't rare: it rejects them outside a channel context — an assistant pane, i.e. a DM with this app — and that refusal is permanent for the conversation, so retrying would deliver nothing forever.

ephemeral ok        -> keep the cooldown
ephemeral refused   -> post in-thread instead   (in a pane the audience is identical)
both failed         -> release the cooldown, let the next turn retry

_post_ephemeral now returns whether it landed. Existing callers ignore it, so the change is additive.

Testing

9 new tests: 403 on create raises, 403 on list raises and does not then attempt a create, 503 stays quiet, 401 stays quiet, the selector path survives a forbidden directory, and the notice is suppressed on a second call.

Verified non-vacuous by removing each guard in turn — the raise tests fail with DID NOT RAISE, and the selector test fails by propagating _SgpAccessForbidden out of a turn that could have been answered.

154 passed
ruff check + format: clean

Note

Whether the linked users actually lack this permission is still unverified — the 403 I measured came from the shared bot's credential, not theirs. This change means that if they do, they'll be told rather than left guessing. Confirming directly needs one of them to try creating a config in SGP.

🤖 Generated with Claude Code

Greptile Summary

The PR distinguishes permanent SGP configuration-access failures from transient failures and adds a rate-limited Slack notice before falling back to the shared bot.

  • Raises a dedicated signal for 403 responses from config listing and creation.
  • Adds ephemeral notification delivery with an in-thread fallback and Redis cooldown.
  • Extends unit coverage for authorization failures, selector fallback, cooldowns, and delivery fallback.

Confidence Score: 4/5

The PR is not yet safe to merge because the notice cooldown can still be retained when both delivery attempts fail.

The fallback assumes a normal return from _deliver means delivery succeeded, although _deliver returns normally when credentials are absent or Slack rejects the post, leaving the user uninformed and suppressing retries for 24 hours.

Files Needing Attention: agentex/src/domain/use_cases/slack_gateway_use_case.py

Important Files Changed

Filename Overview
agentex/src/domain/use_cases/slack_gateway_use_case.py Adds 403 signaling and rate-limited user notification, but the delivery fallback can retain the cooldown after a silent posting failure.
agentex/tests/unit/use_cases/test_slack_gateway_use_case.py Adds focused coverage for SGP failures and notice delivery, but mocks _deliver as either successful or raising and misses its normal-return failure modes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Linked user's Slack turn] --> B[Read or create SGP config]
  B -->|Success| C[Run with user config]
  B -->|403| D[Claim Redis cooldown]
  D -->|Already claimed| E[Use shared bot]
  D -->|Claimed| F[Post ephemeral notice]
  F -->|Delivered| E
  F -->|Rejected| G[Post notice in thread]
  G -->|Raises| H[Release cooldown]
  G -->|Returns normally| E
Loading

Fix all with Greploop Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
agentex/src/domain/use_cases/slack_gateway_use_case.py:2078-2079
**Silent fallback retains cooldown**

When the ephemeral notice is rejected and `_deliver` has no bot token or Slack returns `ok: false`, `_deliver` returns normally without posting anything. This unconditional return then skips `_release_notice_cooldown`, so the user receives no explanation and further attempts remain suppressed for 24 hours.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (3): Last reviewed commit: "fix(agentex): tell a user when SGP won't..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

@michael-chou359
michael-chou359 requested a review from a team as a code owner September 2, 2026 23:23
Comment thread agentex/src/domain/use_cases/slack_gateway_use_case.py
@michael-chou359
michael-chou359 force-pushed the mc/slack-config-forbidden-notice branch from c01c714 to e52b0c0 Compare September 2, 2026 23:28
@michael-chou359 michael-chou359 changed the title fix(agentex): tell a user when their account can't hold a Slack config fix(agentex): tell a user when SGP won't let their account hold a Slack config Sep 2, 2026
…ck config

SGP can refuse an account two ways, and both leave the person with no config of
their own: unable to CREATE one (POST /v5/agent_configs -> 403 "action=create,
legacy_roles=['admin','manager','editor']"), or unable to LIST the directory at all.
The consequence is identical -- every turn silently falls back to the shared bot --
so both are now treated the same.

Before this, such a user kept getting bot-run turns forever with nothing said, having
just completed the link flow, which told them their turns would run as them. They had
no way to find out why their own integrations never work.

Note this is NOT the same permission as running the agent, and lacking it does not
imply lacking access. Agentex authorization (agent.execute) is checked separately and
earlier: the users whose turns hung had tasks created and dispatched, so they could
run golden-agent fine and only the config read failed. A viewer-role account is
exactly the case with execute but not create.

A 403 is an authorization decision, so retrying changes nothing. _list_configs and the
create path both raise _SgpAccessForbidden for it, and _run_turn posts a one-per-day
ephemeral naming the role to ask for before degrading to the bot as before.

Everything else still degrades quietly, because the remedies differ:

  - 401 means the credential reached SGP and was rejected, which a re-link fixes, not
    a role grant -- and the re-link prompt is driven elsewhere, so this only needs to
    be diagnosable rather than messaged twice. Logged apart from the 4xx/5xx bucket.
  - other 4xx/5xx and transport errors may work on the next turn and are not worth
    telling anyone about.

The raise is contained at the two call sites where propagating would be wrong:

  - selector resolution is best-effort and its caller falls back to the default
    config, so raising there would error a turn that can still be answered;
  - the post-create confirmation already has a working config, so a refused re-read
    keeps what was made rather than discarding it.

Both broad `except Exception` handlers re-raise the signal explicitly, since either
would otherwise swallow it and restore the silent behaviour.

The cooldown is only kept if the notice was actually DELIVERED. Claiming it first is
deliberate -- it stops two concurrent turns both posting -- but committing it before
attempting delivery meant a rejected ephemeral suppressed the message for the whole
window while the user was told nothing, which is the failure this is supposed to
prevent.

Slack refusing an ephemeral is not rare: it rejects them outside a channel context,
i.e. an assistant pane (a DM with this app), and that rejection is PERMANENT for the
conversation, so simply retrying would deliver nothing forever. So the notice falls
back to an in-thread message -- in a pane the audience is identical, and in a channel a
visible message beats silence for something the person has to act on. Only if that
fails too is the cooldown released, letting the next turn try again.

_post_ephemeral now returns whether it landed. Existing callers ignore it, so the
change is additive.

Once a day rather than once ever because role grants change, and a stale "ask an
admin" is better repeated occasionally than never retracted. Same SET NX cooldown
shape as the link offer, failing open for the same reason: saying it twice beats never
saying it.

Adds 9 tests: 403 on create raises, 403 on list raises (and does not then attempt a
create), 503 stays quiet, 401 stays quiet, the selector path survives a forbidden
directory, and the notice is suppressed on a second call. Verified non-vacuous by
removing each guard in turn -- the raise tests fail with DID NOT RAISE, and the
selector test fails by propagating _SgpAccessForbidden out of a turn that could have
been answered.

154 passed. ruff check + format clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359
michael-chou359 force-pushed the mc/slack-config-forbidden-notice branch from e52b0c0 to c5c762f Compare September 2, 2026 23:33
Comment on lines +2078 to +2079
await self._deliver(inbound, _SGP_FORBIDDEN_MESSAGE)
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent fallback retains cooldown

When the ephemeral notice is rejected and _deliver has no bot token or Slack returns ok: false, _deliver returns normally without posting anything. This unconditional return then skips _release_notice_cooldown, so the user receives no explanation and further attempts remain suppressed for 24 hours.

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/use_cases/slack_gateway_use_case.py
Line: 2078-2079

Comment:
**Silent fallback retains cooldown**

When the ephemeral notice is rejected and `_deliver` has no bot token or Slack returns `ok: false`, `_deliver` returns normally without posting anything. This unconditional return then skips `_release_notice_cooldown`, so the user receives no explanation and further attempts remain suppressed for 24 hours.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

@michael-chou359
michael-chou359 merged commit 53f82a2 into main Sep 2, 2026
47 checks passed
@michael-chou359
michael-chou359 deleted the mc/slack-config-forbidden-notice branch September 2, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant