Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/memory/feature-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
9 changes: 6 additions & 3 deletions docs/memory/feature-flows/slack-channel-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
40 changes: 39 additions & 1 deletion src/backend/db/slack_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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("""
Expand Down
114 changes: 113 additions & 1 deletion src/backend/routers/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,18 @@ 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"],
"channel_id": binding["slack_channel_id"],
"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"),
}

Expand Down Expand Up @@ -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,
}
81 changes: 72 additions & 9 deletions src/frontend/src/components/SlackChannelPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,37 @@
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ channel.workspace_name }}
<span v-if="channel.is_dm_default" class="ml-1 text-indigo-600 dark:text-indigo-400">(DM default)</span>
</p>
</div>
</div>
<button
@click="unbindChannel"
:disabled="unbinding"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-50"
>
{{ unbinding ? 'Removing...' : 'Unbind' }}
</button>
<div class="flex items-center gap-2">
<!-- DM default control: badge if already default, button otherwise -->
<span
v-if="channel.is_dm_default"
class="inline-flex items-center gap-1 text-xs font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800 rounded px-2 py-1"
:title="dmDefaultTooltip"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
DM default
</span>
<button
v-else
@click="makeDmDefault"
:disabled="makingDefault"
:title="dmDefaultTooltip"
class="text-xs px-2 py-1 border border-indigo-300 dark:border-indigo-700 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ makingDefault ? 'Setting...' : 'Make default' }}
</button>
<button
@click="unbindChannel"
:disabled="unbinding || unbindBlocked"
:title="unbindBlocked ? unbindBlockedTooltip : undefined"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ unbinding ? 'Removing...' : 'Unbind' }}
</button>
</div>
</div>
</div>

Expand Down Expand Up @@ -70,7 +90,7 @@
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import axios from 'axios'

const props = defineProps({
Expand All @@ -83,10 +103,29 @@ const props = defineProps({
const loading = ref(true)
const creating = ref(false)
const unbinding = ref(false)
const makingDefault = ref(false)
const accessDenied = ref(false)
const channel = ref({ bound: false })
const message = ref(null)

const dmDefaultTooltip =
'Direct messages to the bot in this Slack workspace (no @mention, ' +
'no channel context) are routed to the DM-default agent. Only one ' +
'agent per workspace can be the DM default at a time.'

// Unbind is blocked when this agent is the DM default AND other agents
// are bound to the same workspace — otherwise DMs would have nowhere to
// land. The owner has to promote a different agent first. (#584)
const unbindBlocked = computed(
() =>
channel.value.bound &&
channel.value.is_dm_default &&
(channel.value.workspace_agent_count ?? 1) > 1
)
const unbindBlockedTooltip =
'This agent is the DM default for the workspace. Set a different ' +
'agent as DM default first, then you can unbind this one.'

async function loadChannel() {
loading.value = true
message.value = null
Expand Down Expand Up @@ -145,6 +184,30 @@ async function unbindChannel() {
}
}

async function makeDmDefault() {
makingDefault.value = true
message.value = null
try {
const response = await axios.put(
`/api/agents/${props.agentName}/slack/channel/dm-default`
)
const data = response.data
if (data.status === 'unchanged') {
message.value = { type: 'success', text: 'Already the DM default' }
} else {
const prev = data.previous ? ` (was ${data.previous})` : ''
message.value = { type: 'success', text: `Set as DM default${prev}` }
}
await loadChannel()
setTimeout(() => { message.value = null }, 3000)
} catch (e) {
const detail = e.response?.data?.detail || 'Failed to set DM default'
message.value = { type: 'error', text: detail }
} finally {
makingDefault.value = false
}
}

watch(() => props.agentName, () => loadChannel())
onMounted(() => loadChannel())
</script>
7 changes: 7 additions & 0 deletions tests/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@
"categories": ["backend", "unit", "lifecycle", "file-sharing"],
"description": "check_public_folder_mount_matches truth table: enabled+mounted → True, enabled+unmounted → False (needs recreation to attach), disabled+mounted → False (needs recreation to detach), disabled+unmounted → True. Adversarial cases: similar paths (/public-backup, /public/inner) don't match, missing 'Mounts' key handled, flag re-read each call, other mounts (shared-out, shared-in/*, workspace) don't interfere (9 tests)"
},
{
"file": "unit/test_slack_dm_default.py",
"feature": "Slack DM-default agent setter (#584)",
"added": "2026-04-29",
"categories": ["backend", "unit", "db", "slack"],
"description": "set_dm_default + unbind_agent contract: setter is single-tx clear-then-set, idempotent, exclusive (exactly one default per workspace), per-workspace isolation, returns False when agent not bound. Unbind is pure delete (does NOT auto-promote — router enforces the guard), works on non-default and last-agent paths, unknown agent returns False (10 tests)"
},
{
"file": "test_public_chat_history.py",
"feature": "Issue #587",
Expand Down
Loading
Loading