Connect services from one wizard, with no tokens in the browser

Every entry point (the Connectors page and drawer, Add Source, Add
Tool, the chat) opens one connect wizard: sign in or paste the
credentials, choose what to sync (content services only, skippable),
then a summary with Try it in chat. Tool services create their tools on
sign-in with write actions needing approval, and each action's
permission (Always allow, Needs approval, Off) can be changed in the
wizard or the drawer. The drawer also reconnects, syncs more content,
refreshes an MCP server's tools and removes a connection, keeping or
deleting what it feeds.

The file pickers work from a connection id and fetch a short-lived
picker token when they need one; no session token is kept in
localStorage any more, and any left by an older build is claimed once
and removed. S3 and Reddit reuse the keys stored on a connection.

A tool whose account needs signing in pauses on a Connect card in the
chat; after connecting, Continue resumes the call. A connection whose
sign-in stops working raises a Reconnect toast. The Connectors page is
hidden when the backend does not report connectors_enabled.
This commit is contained in:
arc53-machine committed 2026-09-28 17:21:44 +01:00
1 parent 3511004f50
commit 6e2ee43944
43 files changed
+2989 -555

No files matched your search

+1 -1
View File
@@ -1649,7 +1649,7 @@ class ToolExecutor:
# Keys the client needs that are not part of the fixed shape below. They are
# small and optional, and are copied only when present so an ordinary tool
# call does not grow null columns in every persisted row.
_PRESERVED_TOOL_CALL_KEYS = ("artifacts", "device_id")
_PRESERVED_TOOL_CALL_KEYS = ("artifacts", "device_id", "connector_key", "connector_name", "access")
def get_truncated_tool_calls(self) -> List[Dict]:
"""Project tool calls into the shape that is streamed and persisted.
+20 -1
View File
@@ -347,13 +347,29 @@ def _link_api_key_tools(bind) -> None:
def _downgrade_credentials() -> None:
"""Decrypt the envelopes back into the pre-0038 plaintext columns."""
from sqlalchemy import text
bind = op.get_bind()
has_envelope = bind.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'connector_sessions' AND column_name = 'encrypted_credentials'"
)
).first()
if has_envelope is not None:
_decrypt_back(bind)
_restore_account_index()
def _decrypt_back(bind) -> None:
import json
from sqlalchemy import text
from docsgpt.security.encryption import CredentialDecryptionError, decrypt_json
bind = op.get_bind()
# Tools keep their v1 secrets; connections made only for them go away.
bind.execute(text("UPDATE user_tools SET connection_id = NULL WHERE connection_id IN "
"(SELECT id FROM connector_sessions WHERE auth_kind = 'api_key')"))
@@ -384,6 +400,9 @@ def _downgrade_credentials() -> None:
"id": row.id,
},
)
def _restore_account_index() -> None:
# Several accounts per provider cannot survive the old unique index: keep
# the most recently updated one.
op.execute(