mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
* fix: deliver Slack account-link prompt as a visible threaded reply Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral returns ok, but ephemeral messages are silently dropped in Slack's assistant threads (where Open SWE runs), so the user sees nothing. Post the prompt as a normal threaded reply instead — the same channel the agent uses to reply. * fix: deliver Slack auth-failure prompt as a visible threaded reply leave_failure_comment() tried an ephemeral message first and only fell back to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped in Slack's assistant threads, so the fallback never fired and the user saw no auth-failure prompt. Post the visible threaded reply directly, matching the account-link prompt fix. * fix: prompt blocked Slack users with a generic, token-free dashboard link Addresses the review findings that posting the per-user account-link token / auth URL in a visible thread lets any channel member bind their GitHub account to the triggering user's Slack identity. Drop the per-user signed link entirely. Both the account-link prompt (_post_account_link_prompt) and the runtime auth-failure prompt (leave_failure_comment) now post a plain dashboard settings link (build_settings_url) as a visible threaded reply. The user signs in with GitHub from their own session and connects Slack via verified OIDC on the settings page — no secret in the thread, nothing to hijack, and no DM machinery. * feat: nudge first-time users to connect Slack from the dashboard home Show a Connect Slack banner on the agents landing page whenever Slack OAuth is enabled and the user hasn't linked Slack yet. A first-time user (no Slack mapping) sees it immediately after signing in; it disappears once connected. * feat: prompt first-time users to connect Slack via a dialog Replace the inline Connect Slack card on the agents home with a modal dialog (Base UI). It opens automatically once the mapping query resolves to "not connected" and closes itself once Slack is linked; "Maybe later" dismisses it for the session. No new dependency — uses the design system's Base UI. * copy: frame Slack connect as resolving the user's GitHub account Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the Slack thread prompts (blocked + auth-failure), and the settings description. Connecting Slack lets Open SWE resolve the user's GitHub account when they tag it in Slack.
168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from agent.utils import auth
|
|
|
|
|
|
def test_leave_failure_comment_posts_generic_token_free_slack_notice(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Slack auth failures post a generic notice, never the (possibly sensitive) message."""
|
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
|
thread_called: dict[str, str] = {}
|
|
|
|
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
|
|
thread_called["channel_id"] = channel_id
|
|
thread_called["thread_ts"] = thread_ts
|
|
thread_called["message"] = message
|
|
return True
|
|
|
|
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
|
monkeypatch.setattr(
|
|
auth,
|
|
"get_config",
|
|
lambda: {
|
|
"configurable": {
|
|
"slack_thread": {
|
|
"channel_id": "C123",
|
|
"thread_ts": "1.2",
|
|
"triggering_user_id": "U123",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
# Pass a message that embeds a per-user auth URL; it must NOT be echoed publicly.
|
|
asyncio.run(auth.leave_failure_comment("slack", "Click https://auth.example/secret-token"))
|
|
|
|
assert thread_called["channel_id"] == "C123"
|
|
assert thread_called["thread_ts"] == "1.2"
|
|
assert "secret-token" not in thread_called["message"]
|
|
assert "https://app.example.com/my-settings" in thread_called["message"]
|
|
|
|
|
|
def _slack_config(github_login: str | None = "mason-gh") -> dict:
|
|
configurable: dict = {
|
|
"source": "slack",
|
|
"user_email": "mason@example.com",
|
|
"thread_id": "t1",
|
|
}
|
|
if github_login is not None:
|
|
configurable["github_login"] = github_login
|
|
return {"configurable": configurable}
|
|
|
|
|
|
def _stub_dashboard_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
token: str | None,
|
|
expires_at: str | None = "2099-01-01T00:00:00Z",
|
|
cached: tuple[str | None, str | None, str | None] = (None, None, None),
|
|
) -> None:
|
|
from agent.dashboard import profiles
|
|
|
|
async def fake_get_from_thread(thread_id: str):
|
|
return cached
|
|
|
|
async def fake_get_valid(login: str):
|
|
return token
|
|
|
|
async def fake_get_value(namespace, key):
|
|
return {"token_expires_at": expires_at}
|
|
|
|
async def fake_persist(thread_id: str, tok: str, expires_at: str | None = None):
|
|
return "enc"
|
|
|
|
monkeypatch.setattr(auth, "get_github_token_from_thread", fake_get_from_thread)
|
|
monkeypatch.setattr(auth, "persist_encrypted_github_token", fake_persist)
|
|
monkeypatch.setattr(profiles, "get_valid_access_token", fake_get_valid)
|
|
monkeypatch.setattr(profiles, "_get_value", fake_get_value)
|
|
|
|
|
|
def test_resolve_github_token_slack_uses_dashboard_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token="user-tok")
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
|
|
|
|
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
|
|
assert token == "user-tok"
|
|
assert encrypted == "enc"
|
|
assert expires_at == "2099-01-01T00:00:00Z"
|
|
|
|
|
|
def test_resolve_github_token_slack_ignores_stale_thread_cache(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# Slack thread ids are shared, so a prior user's cached token must NOT be
|
|
# returned. Resolution always goes by github_login via the dashboard store.
|
|
_stub_dashboard_store(
|
|
monkeypatch,
|
|
token="bob-token",
|
|
cached=("alice-token", "alice-enc", "2099-01-01T00:00:00Z"),
|
|
)
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
|
|
|
|
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
|
|
assert token == "bob-token"
|
|
|
|
|
|
def test_resolve_github_token_slack_no_token_raises(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token=None)
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
|
|
|
|
with pytest.raises(auth.GitHubUserAuthRequired):
|
|
asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
|
|
|
|
def test_resolve_github_token_per_user_wins_over_bot_only_mode(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token="user-tok")
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
|
|
|
|
async def fail_bot(thread_id: str):
|
|
raise AssertionError("bot token must not be used when a user token exists")
|
|
|
|
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot)
|
|
|
|
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
assert token == "user-tok"
|
|
|
|
|
|
def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token=None)
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
|
|
|
|
async def fake_bot(thread_id: str):
|
|
return ("bot-tok", "bot-enc", None)
|
|
|
|
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
|
|
|
|
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
assert (token, encrypted, expires_at) == ("bot-tok", "bot-enc", None)
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["github", "linear"])
|
|
def test_resolve_github_token_bot_only_mode_non_slack_uses_bot(
|
|
monkeypatch: pytest.MonkeyPatch, source: str
|
|
) -> None:
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
|
|
|
|
async def fake_bot(thread_id: str):
|
|
return ("bot-tok", "bot-enc", None)
|
|
|
|
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
|
|
|
|
config = {"configurable": {"source": source, "github_login": "octo", "thread_id": "t1"}}
|
|
token, _, _ = asyncio.run(auth.resolve_github_token(config, "t1"))
|
|
assert token == "bot-tok"
|