diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index 919fb7f90..46d7212ff 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -11,6 +11,7 @@ | Date | ID | Feature | Flow | |------|-----|---------|------| +| 2026-04-29 | #584 | feat(slack): UI + API to change Slack DM-default agent — `set_slack_dm_default()` DB method (single-tx clear-then-set), `PUT /api/agents/{name}/slack/channel/dm-default` (owner-only, audit-logged), "Make default" button + tooltip in `SlackChannelPanel.vue`, unbind refuses 409 when target is DM default with siblings remaining | [slack-channel-routing.md](feature-flows/slack-channel-routing.md) | | 2026-04-30 | #598 | sec: AISEC-C2 Layer 2 — restored `.mcp.json` post-deploy editing via structure validation (`services.mcp_validator`). Closed schema, command/transport allowlists, SSRF guard for http/sse, reserved env-ref blocklist, literal-secret detection. 88 unit tests + 22 integration tests. UI placeholder updated; `trinity` server name reserved. | [credential-injection.md](feature-flows/credential-injection.md) | | 2026-04-30 | #590 | sec: AISEC-C2 Layer 1 — backend `ALLOWED_CREDENTIAL_PATHS` tightened; backend `update_agent_file_logic` adds defense-in-depth deny check before proxy; agent-server `EDIT_PROTECTED_PATHS` adds `.mcp.json` and `.credentials.enc`. | [credential-injection.md](feature-flows/credential-injection.md), [file-browser.md](feature-flows/file-browser.md) | | 2026-04-30 | #364 | Web chat file upload — drag-drop/picker in ChatPanel and PublicChat; base64 JSON encoding; shared upload_service; images via vision blocks, non-images via Docker put_archive | [web-chat-file-upload.md](feature-flows/web-chat-file-upload.md) | diff --git a/docs/memory/feature-flows/slack-channel-routing.md b/docs/memory/feature-flows/slack-channel-routing.md index fffc9296e..f395fd73c 100644 --- a/docs/memory/feature-flows/slack-channel-routing.md +++ b/docs/memory/feature-flows/slack-channel-routing.md @@ -38,7 +38,7 @@ As a **platform admin**, I want Slack messages to go through the same execution ### Agent Detail — Sharing Tab (Per-Agent) - `SlackChannelPanel.vue` — Three states: - - **Bound**: Shows `#channel-name`, workspace name, DM default badge, "Unbind" button + - **Bound**: Shows `#channel-name`, workspace name, DM-default badge **or** "Make default" button (with hover tooltip explaining DM routing), and "Unbind" button. The Unbind button is **disabled** when this agent is the DM default *and* the workspace has other bound agents — promoting another agent first via the "Make default" button on its panel is required (#584). - **Unbound**: "Create Slack Channel" button → creates channel in Slack + binds to agent - **Access denied**: Informational message for non-owner shared users - `SharingPanel.vue` — Renders `SlackChannelPanel` between Team Sharing and Public Links sections @@ -55,9 +55,10 @@ As a **platform admin**, I want Slack messages to go through the same execution - `POST /api/settings/slack/install` → `{oauth_url}` (browser redirect) ### API Calls (Per-Agent Channel) -- `GET /api/agents/{name}/slack/channel` → `{bound, channel_name, channel_id, workspace_name}` +- `GET /api/agents/{name}/slack/channel` → `{bound, channel_name, channel_id, workspace_name, is_dm_default, workspace_agent_count}` - `POST /api/agents/{name}/slack/channel` → `{status, channel_name, channel_id, workspace_name}` -- `DELETE /api/agents/{name}/slack/channel` → `{unbound, workspace_name}` +- `DELETE /api/agents/{name}/slack/channel` → `{unbound, workspace_name}` — **409** if the agent is the workspace's DM default and other agents are still bound (#584) +- `PUT /api/agents/{name}/slack/channel/dm-default` → `{status, team_id, workspace_name, previous, new_default}` — owner-only; single-tx clear-then-set on `is_dm_default`; audit-logged via `AGENT_LIFECYCLE/slack_dm_default_changed` (#584) ## Backend Layer @@ -110,6 +111,8 @@ Priority in `SlackAdapter.get_agent_name()`: | GET | `/api/agents/{name}/public-links/{id}/slack` | `routers/slack.py` | Connection status | | DELETE | `/api/agents/{name}/public-links/{id}/slack` | `routers/slack.py` | Disconnect | | PUT | `/api/agents/{name}/public-links/{id}/slack` | `routers/slack.py` | Update settings (enable/disable) | +| PUT | `/api/agents/{name}/slack/channel/dm-default` | `routers/slack.py` | Make this agent the DM-default for its workspace (#584) | +| DELETE | `/api/agents/{name}/slack/channel` | `routers/slack.py` | Unbind — refuses with 409 if agent is the DM default and others are bound (#584) | ### Business Logic diff --git a/src/backend/database.py b/src/backend/database.py index e5bac9ec7..2b0032355 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1536,6 +1536,9 @@ def get_slack_agent_name_for_channel(self, team_id, slack_channel_id): def get_slack_dm_default_agent(self, team_id): return self._slack_channel_ops.get_dm_default_agent(team_id) + def set_slack_dm_default(self, team_id, agent_name): + return self._slack_channel_ops.set_dm_default(team_id, agent_name) + def get_slack_agents_for_workspace(self, team_id): return self._slack_channel_ops.get_agents_for_workspace(team_id) diff --git a/src/backend/db/slack_channels.py b/src/backend/db/slack_channels.py index 4779ecad2..f962d6dbb 100644 --- a/src/backend/db/slack_channels.py +++ b/src/backend/db/slack_channels.py @@ -197,6 +197,37 @@ def get_dm_default_agent(self, team_id: str) -> Optional[str]: row = cursor.fetchone() return row[0] if row else None + def set_dm_default(self, team_id: str, agent_name: str) -> bool: + """Make ``agent_name`` the DM-default for the workspace. + + Single transaction: clear all existing flags, then set on the target. + Avoids any window where two agents would both look like the default + (the schema has no exclusivity constraint, so the read-side falls + back to ``LIMIT 1`` and would pick non-deterministically). + + Returns True if the target row was updated, False if the agent is + not bound in this workspace (caller should 404). + """ + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("BEGIN") + try: + cursor.execute( + "UPDATE slack_channel_agents SET is_dm_default = 0 WHERE team_id = ?", + (team_id,), + ) + cursor.execute( + """UPDATE slack_channel_agents SET is_dm_default = 1 + WHERE team_id = ? AND agent_name = ?""", + (team_id, agent_name), + ) + changed = cursor.rowcount > 0 + conn.commit() + except Exception: + conn.rollback() + raise + return changed + def get_agents_for_workspace(self, team_id: str) -> List[dict]: """Get all agent-channel bindings for a workspace.""" with get_db_connection() as conn: @@ -229,7 +260,14 @@ def get_channel_for_agent(self, team_id: str, agent_name: str) -> Optional[dict] return self._row_to_channel_agent(row) def unbind_agent(self, team_id: str, agent_name: str) -> bool: - """Remove an agent's channel binding.""" + """Remove an agent's channel binding. + + Pure delete — does not auto-promote a new DM default. The router + layer is responsible for refusing to unbind the current DM default + while other agents are still bound (#584). When the unbind target + is the only agent in the workspace, the binding is removed cleanly + and the workspace ends up with no Slack agents at all. + """ with get_db_connection() as conn: cursor = conn.cursor() cursor.execute(""" diff --git a/src/backend/routers/slack.py b/src/backend/routers/slack.py index 5eeed22cc..4132c8b78 100644 --- a/src/backend/routers/slack.py +++ b/src/backend/routers/slack.py @@ -405,6 +405,10 @@ async def get_agent_slack_channel( for ws in workspaces: binding = db.get_slack_channel_for_agent(ws["team_id"], name) if binding: + # Count agents in the workspace so the UI can decide whether + # to allow unbinding — the DM-default agent cannot be unbound + # while other agents are still bound (#584). + workspace_agents = db.get_slack_agents_for_workspace(ws["team_id"]) return { "bound": True, "channel_name": binding["slack_channel_name"], @@ -412,6 +416,7 @@ async def get_agent_slack_channel( "workspace_team_id": ws["team_id"], "workspace_name": ws["team_name"], "is_dm_default": binding.get("is_dm_default", False), + "workspace_agent_count": len(workspace_agents), "created_at": binding.get("created_at"), } @@ -490,14 +495,121 @@ async def delete_agent_slack_channel( name: str, current_user: User = Depends(get_current_user) ): - """Unbind an agent from its Slack channel.""" + """Unbind an agent from its Slack channel. + + Refuses to unbind the workspace's current DM-default agent while any + other agents are still bound (#584). The owner must promote a different + agent first via ``PUT /api/agents/{name}/slack/channel/dm-default``. + When the agent is the only one bound, unbind is allowed — the workspace + ends up with no Slack agents, which is a clean cascade. + """ if not db.can_user_share_agent(current_user.username, name): raise HTTPException(status_code=403, detail="Only owners can manage Slack channels") workspaces = db.get_all_slack_workspaces() for ws in workspaces: + binding = db.get_slack_channel_for_agent(ws["team_id"], name) + if not binding: + continue + + # Refuse to drop the DM default while siblings remain. + if binding.get("is_dm_default"): + workspace_agents = db.get_slack_agents_for_workspace(ws["team_id"]) + if len(workspace_agents) > 1: + raise HTTPException( + status_code=409, + detail=( + "Cannot unbind the DM-default agent while other agents " + "are bound to this workspace. Set another agent as DM " + "default first (PUT /api/agents/{other}/slack/channel/" + "dm-default) and try again." + ), + ) + if db.unbind_slack_agent(ws["team_id"], name): logger.info(f"Agent {name} unbound from Slack in workspace {ws['team_name']}") return {"unbound": True, "workspace_name": ws["team_name"]} raise HTTPException(status_code=404, detail="Agent is not bound to any Slack channel") + + +@auth_router.put("/api/agents/{name}/slack/channel/dm-default") +async def set_agent_as_slack_dm_default( + name: str, + current_user: User = Depends(get_current_user) +): + """Make this agent the DM-default for its Slack workspace. + + DMs to the bot (no channel context, no @mention) route to whichever + agent in the workspace is flagged ``is_dm_default=1``. Until #584 the + flag was only auto-set for the first agent ever connected and had no + setter, so workspaces with multiple agents were stuck. This endpoint + flips it; ``unbind`` auto-promotes the oldest remaining agent so the + workspace is never left with zero defaults. + """ + if not db.can_user_share_agent(current_user.username, name): + raise HTTPException(status_code=403, detail="Only owners can manage Slack channels") + + # Find the workspace where this agent is bound. There should be at + # most one — agents are bound 1:1 per workspace today. + workspace = None + for ws in db.get_all_slack_workspaces(): + if db.get_slack_channel_for_agent(ws["team_id"], name): + workspace = ws + break + + if not workspace: + raise HTTPException( + status_code=404, + detail="Agent is not bound to any Slack channel", + ) + + team_id = workspace["team_id"] + previous = db.get_slack_dm_default_agent(team_id) + if previous == name: + # Idempotent — already the default. + return { + "status": "unchanged", + "team_id": team_id, + "workspace_name": workspace.get("team_name"), + "previous": previous, + "new_default": name, + } + + if not db.set_slack_dm_default(team_id, name): + # set_dm_default returns False only if the agent isn't bound — we + # already verified that above, so this is a real "row vanished" + # race. Surface as 404. + raise HTTPException(status_code=404, detail="Agent binding not found") + + logger.info( + "Slack DM default for workspace %s changed: %s → %s (by %s)", + workspace.get("team_name"), previous, name, current_user.username, + ) + + # Audit + try: + await platform_audit_service.log( + event_type=AuditEventType.AGENT_LIFECYCLE, + event_action="slack_dm_default_changed", + source="api", + actor_user=current_user, + target_type="agent", + target_id=name, + details={ + "team_id": team_id, + "workspace_name": workspace.get("team_name"), + "previous": previous, + "new_default": name, + }, + ) + except Exception as e: # pragma: no cover + logger.warning("Failed to audit slack_dm_default_changed: %s", e) + + return { + "status": "updated", + "team_id": team_id, + "workspace_name": workspace.get("team_name"), + "previous": previous, + "new_default": name, + } diff --git a/src/frontend/src/components/SlackChannelPanel.vue b/src/frontend/src/components/SlackChannelPanel.vue index bfb6c9c36..2b315d7a3 100644 --- a/src/frontend/src/components/SlackChannelPanel.vue +++ b/src/frontend/src/components/SlackChannelPanel.vue @@ -30,17 +30,37 @@
{{ channel.workspace_name }} - (DM default)
- +