Skip to content

feat(credentials): agent-initiated oauth credential reconnect#5488

Open
j15z wants to merge 1 commit into
stagingfrom
feat/agent-reconnect-credential
Open

feat(credentials): agent-initiated oauth credential reconnect#5488
j15z wants to merge 1 commit into
stagingfrom
feat/agent-reconnect-credential

Conversation

@j15z

@j15z j15z commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The agent can now generate a Reconnect link for an existing OAuth credential: oauth_get_auth_link accepts an optional credentialId (tool param + prompts land in the companion mothership PR)
  • /api/auth/oauth2/authorize accepts credentialId: requires credential-admin access (same check as the draft POST route), rejects cross-workspace / non-oauth / provider-mismatched ids, and threads the id into the pending credential draft so the existing handleReconnectCredential path rebinds the same credential.id to the fresh account row
  • Draft upsert writes credentialId (or null) on both the insert and conflict paths — a plain connect can never inherit a stale reconnect draft and silently rebind an existing credential
  • Tool handler validates the credential at link-generation time so failures surface in the tool result (agent-visible) instead of a silent browser redirect; Trello/Shopify reconnect is rejected with a pointer to the integrations page (their custom authorize flows bypass the draft-creating endpoint)
  • Extracted the connect modal's display-name collision numbering into lib/credentials/display-name.ts and applied it to copilot-path connects — agent-created credentials get "Justin's Gmail 2" instead of identical duplicate names
  • Chat credential chip renders "Reconnect {credential display name}" when the link URL carries a credentialId (derived from the URL, not from model-emitted tag fields)

Note: tool-schemas-v1.ts has a ~195-line key-style diff — pre-existing generator drift caught up by regen (bun run mship:check passes); not feature churn.

Merge order: this PR merges/deploys before the mothership companion — the new tool param crosses the tool-catalog codegen boundary.

Type of Change

  • New feature

Testing

  • 31 new unit tests: authorize route (reconnect authz, provider mismatch, draft cross-contamination), tool handler validation, display-name behavior pinning, chip label; neighboring suites green (745 tests)
  • bun run lint clean, bun run check:api-validation:strict passed, tsc clean
  • Manual E2E: reconnect keeps credential.id, swaps the account row, deletes the orphaned account, dependent workflow still runs

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

🤖 Generated with Claude Code

Companion PRs

  • simstudioai/mothership#344

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 7, 2026 7:45pm

Request Review

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches OAuth authorize, credential-admin authorization, and draft upsert semantics where a bug could rebind the wrong credential; mitigated by explicit nulling on plain connect and layered validation in the route and tool handler.

Overview
Adds OAuth reconnect end-to-end: the copilot oauth_get_auth_link tool accepts optional credentialId, validates the credential server-side, and builds authorize URLs that rebind the same credential instead of creating a new one.

/api/auth/oauth2/authorize now accepts credentialId. Reconnect paths require credential-admin access (matching the draft POST route), reject wrong workspace / non-OAuth / provider mismatch, and store credentialId on the pending draft for the existing reconnect callback. Draft upserts always set credentialId to the value or null so a plain connect cannot inherit a stale reconnect draft.

Display names for server-created connect drafts use shared defaultCredentialDisplayName (extracted from the connect modal), including collision numbering against workspace OAuth credentials.

Chat credential link chips parse credentialId from the URL and show “Reconnect {display name}” when present; Trello/Shopify reconnect from chat is blocked with a pointer to the integrations page.

Reviewed by Cursor Bugbot for commit 5baf4ab. Bugbot is set up for automated code reviews on this repo. Configure here.

@j15z

j15z commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor review

@github-actions github-actions Bot added the requires-mothership-merge Has a companion PR on the mothership/copilot side — merge in lockstep label Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

⚠️ Cross-repo companion check

One or more companion PRs aren't merged into staging yet. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.

  • simstudioai/mothership#344OPEN, not merged (targets staging) — feat(tools): reconnect support for oauth_get_auth_link via credentialId

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds agent-initiated OAuth credential reconnect support: the oauth_get_auth_link tool now accepts an optional credentialId, the authorize endpoint validates and threads it into the pending draft, and the existing handleReconnectCredential path rebinds the credential's accountId without changing its id. It also extracts the connect-modal's display-name collision logic into a shared lib/credentials/display-name.ts utility, applies it to server-side (agent-created) connects, and updates the chat credential chip to read credentialId from the link URL and label the chip "Reconnect {displayName}" accordingly.

  • authorize/route.ts: New credentialId query param triggers credential-admin, workspace, type, and provider-mismatch guards before the draft is written; the upsert explicitly clears credentialId on both insert and conflict paths to prevent stale reconnect draft leakage.
  • oauth.ts (tool handler): Pre-validates the credential at link-generation time (including a Trello/Shopify fast-reject), surfacing errors agent-visibly rather than as a silent browser redirect.
  • special-tags.tsx: CredentialLinkDisplay derives credentialId from the URL (not model-emitted tag fields) and fetches the credential name via useWorkspaceCredential, falling back to the raw provider string while loading.

Confidence Score: 4/5

Safe to merge with the audit-log display name caveat noted — the credential itself is always reconnected correctly.

The auth guards, workspace isolation, provider-mismatch check, and draft contamination prevention are all solid. The one defect is that agent-initiated reconnects write an incremented display name (e.g., "Justin's Gmail 2") into the draft because the credential being reconnected is already counted in takenNames; handleReconnectCredential uses that name only for the audit log — the credential's actual display name and accountId update are unaffected. Audit records for reconnects will therefore mis-identify the credential name until this is corrected.

apps/sim/app/api/auth/oauth2/authorize/route.ts — the createConnectDraft collision-numbering path for reconnects.

Important Files Changed

Filename Overview
apps/sim/app/api/auth/oauth2/authorize/route.ts Adds credentialId reconnect path to the authorize endpoint — correct authz, workspace, type, and provider-mismatch guards; however, createConnectDraft includes the reconnecting credential's own name in takenNames, causing the audit log to record the wrong displayName.
apps/sim/lib/copilot/tools/handlers/oauth.ts Adds credential validation at tool-call time for reconnect, surfacing errors agent-visibly; Trello/Shopify correctly rejected before the DB call; validation mirrors the route's guards.
apps/sim/lib/credentials/display-name.ts Clean extraction of defaultCredentialDisplayName from the connect modal into a shared utility; logic unchanged, exported constant, well-tested.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx CredentialLinkDisplay correctly extracts credentialId from the URL (not from model-emitted fields), fetches the credential name via useWorkspaceCredential, and labels the chip "Reconnect {displayName}" with a graceful loading fallback.
apps/sim/lib/api/contracts/oauth-connections.ts Adds optional credentialId to authorizeOAuth2QuerySchema with a min(1) guard — clean, minimal change.
apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx Replaces inline defaultDisplayName with the new shared utility; no behavioral change.
apps/sim/lib/copilot/generated/tool-catalog-v1.ts Generated file — adds credentialId as an optional parameter to OauthGetAuthLink with a clear description discouraging misuse.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent
    participant ToolHandler as oauth.ts
    participant AuthorizeRoute as authorize route
    participant DB
    participant BetterAuth
    participant Browser

    Agent->>ToolHandler: executeOAuthGetAuthLink(providerName, credentialId)
    ToolHandler->>DB: getCredentialActorContext — validate type/workspace/admin
    DB-->>ToolHandler: actor
    ToolHandler-->>Agent: oauth_url with credentialId param

    Note over Browser: User clicks Reconnect chip
    Browser->>AuthorizeRoute: "GET authorize?providerId=X&credentialId=cred-1"
    AuthorizeRoute->>DB: checkWorkspaceAccess
    AuthorizeRoute->>DB: getCredentialActorContext — re-validate admin+type+provider
    AuthorizeRoute->>DB: createConnectDraft with credentialId
    AuthorizeRoute->>BetterAuth: oAuth2LinkAccount
    BetterAuth-->>AuthorizeRoute: provider URL
    AuthorizeRoute-->>Browser: redirect to provider

    Browser->>BetterAuth: OAuth callback
    BetterAuth->>DB: account.create.after hook
    DB->>DB: handleReconnectCredential — UPDATE credential SET accountId
    Note over DB: credential.id preserved, old orphaned account deleted
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent
    participant ToolHandler as oauth.ts
    participant AuthorizeRoute as authorize route
    participant DB
    participant BetterAuth
    participant Browser

    Agent->>ToolHandler: executeOAuthGetAuthLink(providerName, credentialId)
    ToolHandler->>DB: getCredentialActorContext — validate type/workspace/admin
    DB-->>ToolHandler: actor
    ToolHandler-->>Agent: oauth_url with credentialId param

    Note over Browser: User clicks Reconnect chip
    Browser->>AuthorizeRoute: "GET authorize?providerId=X&credentialId=cred-1"
    AuthorizeRoute->>DB: checkWorkspaceAccess
    AuthorizeRoute->>DB: getCredentialActorContext — re-validate admin+type+provider
    AuthorizeRoute->>DB: createConnectDraft with credentialId
    AuthorizeRoute->>BetterAuth: oAuth2LinkAccount
    BetterAuth-->>AuthorizeRoute: provider URL
    AuthorizeRoute-->>Browser: redirect to provider

    Browser->>BetterAuth: OAuth callback
    BetterAuth->>DB: account.create.after hook
    DB->>DB: handleReconnectCredential — UPDATE credential SET accountId
    Note over DB: credential.id preserved, old orphaned account deleted
Loading

Reviews (1): Last reviewed commit: "feat(credentials): agent-initiated oauth..." | Re-trigger Greptile

Comment on lines +54 to +65
let takenNames: ReadonlySet<string> = new Set<string>()
try {
const rows = await db
.select({ displayName: credential.displayName })
.from(credential)
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
takenNames = new Set(rows.map((row) => row.displayName.toLowerCase()))
} catch {
// Fall back to service name only
// Best effort — proceed without collision numbering
}

const displayName = defaultCredentialDisplayName(userName, serviceName, takenNames)

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 Reconnect draft display name includes the credential being reconnected in takenNames

For a reconnect, createConnectDraft queries all workspace OAuth credentials into takenNames — including the credential that is being reconnected (e.g., "Justin's Gmail"). defaultCredentialDisplayName sees that name as taken and returns "Justin's Gmail 2". handleReconnectCredential then uses draft.displayName as both resourceName and the description field in the audit record, so the audit log for the reconnect event will read Reconnected OAuth credential "Justin's Gmail 2" when the credential is actually named "Justin's Gmail". The reconnect itself is functionally correct (accountId is updated without touching displayName), but the audit trail is inaccurate for every agent-initiated reconnect where the credential's display name matches the generated default pattern.

The fix is to skip collision numbering when credentialId is present — for reconnects the draft's displayName should reflect the existing credential's actual name, not a freshly de-duplicated one.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds agent-initiated OAuth credential reconnect: the oauth_get_auth_link tool and the /api/auth/oauth2/authorize route both accept an optional credentialId that threads through the pending credential draft so the existing handleReconnectCredential path rebinds the credential in place. It also extracts display-name collision-numbering into a shared lib/credentials/display-name.ts and updates the chat credential chip to show "Reconnect {name}" when the link URL carries a credentialId.

  • Authorization route (route.ts): validates credential ownership, workspace membership, OAuth type, provider match, and admin role before creating a reconnect draft; upsert explicitly sets credentialId: null on the conflict path so a stale reconnect draft cannot leak into a plain connect.
  • Tool handler (oauth.ts): validates the same constraints at link-generation time so failures surface in the tool result; Trello/Shopify reconnect is blocked with a pointer to the integrations page.
  • Chat chip (special-tags.tsx): derives reconnectCredentialId from the URL rather than model-emitted fields and resolves the display name via useWorkspaceCredential.

Confidence Score: 4/5

The reconnect authorization chain is well-guarded with redundant credential-admin checks at both the tool-handler and route layers. The main imprecision is in the audit log for reconnects, where the draft display name gets a collision suffix because the credential being reconnected is counted in takenNames.

Cross-workspace attacks, non-admin reconnects, type and provider mismatches, and draft cross-contamination are all explicitly checked and tested. The two issues found are the misleading audit log name during reconnects and a redundant workspace-access DB round-trip in the tool handler — neither affects credential data or user-facing behavior.

apps/sim/app/api/auth/oauth2/authorize/route.ts — the createConnectDraft call in the reconnect path should skip or narrow collision detection so the draft's displayName matches the existing credential name for correct audit logs.

Important Files Changed

Filename Overview
apps/sim/app/api/auth/oauth2/authorize/route.ts Adds credentialId query param support to the authorize GET route; validates credential ownership, workspace membership, and provider match before creating a reconnect draft. The draft's displayName is incorrectly collision-checked against all credentials (including the one being reconnected), producing a suffixed name in the audit log while leaving the actual credential name unchanged.
apps/sim/lib/copilot/tools/handlers/oauth.ts Extends the OAuth tool handler with reconnect validation (credential existence, workspace, type, provider, admin checks) before embedding credentialId in the authorize URL. Calls getCredentialActorContext without the pre-fetched workspace access, causing a redundant DB round-trip per reconnect link generation.
apps/sim/lib/credentials/display-name.ts New module extracting the defaultCredentialDisplayName helper from the connect modal into a shared library; clean extraction with unchanged logic and full unit test coverage.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx Extracts link rendering into CredentialLinkDisplay; derives reconnect label from the URL's credentialId param via useWorkspaceCredential rather than trusting model-emitted fields, with safe URL parsing and fallback to provider ID while loading.
apps/sim/lib/api/contracts/oauth-connections.ts Adds optional credentialId field (min-length 1) to authorizeOAuth2QuerySchema; straightforward contract extension.

Reviews (2): Last reviewed commit: "feat(credentials): agent-initiated oauth..." | Re-trigger Greptile

Comment on lines +54 to +65
let takenNames: ReadonlySet<string> = new Set<string>()
try {
const rows = await db
.select({ displayName: credential.displayName })
.from(credential)
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
takenNames = new Set(rows.map((row) => row.displayName.toLowerCase()))
} catch {
// Fall back to service name only
// Best effort — proceed without collision numbering
}

const displayName = defaultCredentialDisplayName(userName, serviceName, takenNames)

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.

P2 Reconnect draft gets wrong display name in audit log

When credentialId is set (reconnect path), takenNames is populated from all oauth credentials in the workspace — which includes the credential being reconnected. So defaultCredentialDisplayName returns a suffixed name (e.g., "Justin's Gmail 2" instead of "Justin's Gmail") because the credential's own name is already in the set. handleReconnectCredential uses draft.displayName only for the audit log (resourceName, description) and never writes it back to the credential's displayName column, so the credential itself is unaffected — but the audit entry reads "Reconnected OAuth credential 'Justin's Gmail 2'" when the credential is still named 'Justin's Gmail'.

For the reconnect case, collision detection provides no value (no new credential is being named), so takenNames should be passed as an empty set when credentialId is provided.

`integrations page and press Reconnect on the credential there.`
)
}
const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId)

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.

P2 Redundant workspace-access query in reconnect validation

ensureWorkspaceAccess (called on line 20) fetches workspace access via checkWorkspaceAccess internally but discards the result. getCredentialActorContext here is called without { workspaceAccess }, so it re-fetches workspace access for the same user/workspace pair a second time. The route handler avoids this by passing { workspaceAccess: access } from the already-resolved checkWorkspaceAccess result. Consider returning the access object from ensureWorkspaceAccess (or using a local checkWorkspaceAccess call in generateOAuthLink) so it can be threaded into getCredentialActorContext.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5baf4ab. Configure here.

// (and freshly clocked) when the OAuth callback's `account.create.after`
// hook runs. If this throws, we never start the OAuth flow.
await createConnectDraft({ userId, workspaceId, providerId })
await createConnectDraft({ userId, workspaceId, providerId, credentialId })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Trello Shopify reconnect draft leak

Medium Severity

The OAuth2 authorize route accepts credentialId for Trello and Shopify and always writes a reconnect pending draft before calling oAuth2LinkAccount, but those providers use separate authorize flows and are rejected in the copilot tool. A failed or abandoned /api/auth/oauth2/authorize attempt can leave a reconnect draft that Trello/Shopify token storage later consumes via processCredentialDraft, rebinding the credential unintentionally.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5baf4ab. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

requires-mothership-merge Has a companion PR on the mothership/copilot side — merge in lockstep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant