Skip to content

improvement(egress): route workspace requests through the organization's egress gateway - #10040

Open
waleedlatif1 wants to merge 25 commits into
stagingfrom
improvement/org-egress-credentials
Open

waleedlatif1 wants to merge 25 commits into
stagingfrom
improvement/org-egress-credentials

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Requests Sim makes on a workspace's behalf now leave through the organization's dedicated egress gateway (its static IPs) when it has one. Before, only secureFetch* and generic tool HTTP were routed; raw fetch and the AWS SDK always used shared IPs, so token mints, OAuth refreshes, tenant API calls and many tool clients bypassed the gateway
  • New outboundFetch (lib/core/network/outbound-fetch.server.ts), a drop-in for fetch: gateway org → gateway; every other route → the global fetch, unchanged. Outside any scope it goes direct with a warning instead of failing closed, so a path that never set a scope can't become an outage. scopedOutboundFetch keeps knowledge connectors fail-closed as before
  • New organizationAwsRequestHandler for AWS SDK clients signing with customer credentials (STS, AWS tools, BYOK Bedrock): gateway orgs tunnel per org (no socket shared across orgs); everyone else keeps the exact handler they had, including Bedrock's HTTP/2
  • Converted: credential token mints and verifies (fetchProvider, Databricks, NetSuite, SailPoint, CrowdStrike, Entra, Google WIF, Atlassian, Snowflake PAT), OAuth refresh, OAuth connect (custom exchanges, plus a routed default code exchange built from better-auth's own request builder), lib/internal tool clients, tools, webhook subscriptions and polling, LLM/embedding providers (incl. Gemini/Vertex via httpOptions.fetch), data drains, selectors
  • Sim-owned destinations stay direct: internal services, Sim's storage/billing/telemetry, Sim's own Azure deployments and fal billing account, operator-local Ollama/vLLM/LiteLLM
  • Added outbound scopes where a workspace's request had none: OAuth connect callbacks (from the user's connect draft), Teams subscription renewal cron, Slack search, Slack managed users, wand, BYOK model listings, v1 knowledge search
  • An egress outage now returns 503 and never marks a credential dead or revoked (was a 401 "failed to refresh")
  • Removed /api/auth/oauth/token (GET/POST) and its contracts and route-only helpers: nothing calls it since in-process token resolution (improvement(oauth): resolve credential tokens in-process instead of hopping the token route #7340), and no sibling repo references it
  • New check:organization-egress AST audit: a global fetch reference in code that acts for a workspace fails unless annotated // egress-direct: <reason>; the rule is documented in CLAUDE.md

Type of Change

  • Improvement

Testing

  • Real-socket tests through a local CONNECT gateway: outboundFetch and the AWS handler go through the scoped org's gateway, each org gets its own tunnel, and pooled sockets are never shared across orgs (fail when replaced with plain fetch / awsRequestHandler / a shared pool)
  • Equivalence tests: the routed code exchange sends byte-identical requests to better-auth's own generic-oauth exchange (basic+PKCE, post+headers, discovery, token params, error/redirect)
  • Scope tests for the callbacks, cron, Slack search, Slack managed users and v1 KB search; 503 mapping tests for refresh, token resolution, managed OAuth and credential groups (each red without its fix)
  • Targeted vitest across every touched directory; check:organization-egress (+ its tests), check:egress-boundary, check:api-validation:strict, check:test-patterns, check:boundaries, check:client-boundary, check:explicit-any, check:source-conventions, check:file-names, check:comment-hygiene, lint and type-check pass locally; full suite and check:audits in CI
  • Independent reviews (routing core, call-site classification, OAuth/route removal) plus /simplify and /cleanup passes, findings applied

Checklist

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

… providers, and runtime libs through outboundFetch

Vendor API calls made for a workspace (tool post-processing, webhook
subscription and polling, LLM provider SDK clients, embeddings, OCR,
reranking, data drains, KB connectors and access checks, selectors,
managed agents, media) now use outboundFetch so an organization with a
dedicated egress gateway sends them from its static IPs. Sim-internal,
operator-local (Ollama, vLLM/LiteLLM env endpoints), and platform calls
stay on the global fetch.

Slack Search deliveries (home publish, stop, message response, onboarding
sender check) now run in the installation's organization scope; the
public marketplace install exchange runs as a platform request.
…h the organization's gateway

organizationAwsRequestHandler resolves the outbound route per request: a gateway route
tunnels through a per-organization keep-alive agent (never shared across organizations or
with direct traffic), a direct route keeps awsRequestHandler's behaviour, and a call
without a scope goes direct with a warning. With routing disabled it is awsRequestHandler.

Used for AWS tool clients (awsClientConfig, unauthenticated STS), saved-credential STS
AssumeRole / AssumeRoleWithWebIdentity / GetCallerIdentity, and Bedrock on customer
credentials. Sim's own AWS usage keeps awsRequestHandler. The gateway connect sequence is
shared with createGatewayDispatcher via connectThroughGateway, and an OutboundRoutingError
is reported as unavailable rather than as a rejected credential.
…on gateway

- refreshOAuthToken (incl. the Instagram long-lived refresh) and its provider
  config move to the server-only lib/oauth/refresh.server.ts, so the
  outboundFetch import never enters client bundles through lib/oauth/oauth.ts
- vendor token exchange, refresh, userinfo, revoke, and discovery calls in
  lib/oauth, lib/auth/connectors, and lib/atlassian use outboundFetch
- Instagram, Trello, and Shopify connect callbacks resolve their outbound scope
  from the caller's connect draft owner
- the Teams subscription renewal cron scopes each subscription to its
  workflow's workspace organization
- remove the unused /api/auth/oauth/token route, its contracts, and the
  delegation-header authenticator only it used
…stings, and v1 kb search embeddings through the workspace egress route

Pass outboundFetch as the @google/genai httpOptions.fetch for the Google, Vertex,
file-attachment, and vision clients. Run wand OpenAI calls, BYOK/hosted model
catalog listings, and v1 knowledge search query embeddings inside the canonical
workspace's outbound scope after access is verified.
…raft and report egress outages as 503

Generic OAuth connectors' custom getToken/getUserInfo now run in the outbound scope of
the connect draft named by Better Auth's verified OAuth state, so an organization with
a dedicated egress gateway connects from its own IPs. The Salesforce instance lookup in
account.create.before uses the same draft scope.

An OutboundRoutingError during OAuth refresh is no longer swallowed into a generic
failed refresh: it propagates through the coalesced refresh without marking the
credential dead or revoked, and token resolution maps it (and the service-account path)
to 503 instead of 401.
…changes through the organization gateway

Connectors without their own getToken now get a default one that performs Better
Auth's generic OAuth exchange (authorizationCodeRequest, discovery-resolved token
endpoint, basic/post client auth, PKCE, static tokenUrlParams, redirect refusal,
getOAuth2Tokens) but sends it with outboundFetch inside the connect draft's scope.
Credential-group managed OAuth exchanges the code the same way and reports an
unavailable egress route as 503. A managed OAuth refresh that hits an unavailable
egress route reports a retryable 503 refresh failure instead of an unhandled error,
without marking the credential for reauthorization.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner November 14, 2026 05:17
@vercel

vercel Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
docs Ignored Ignored Preview Nov 14, 2026 6:04am UTC

Request Review

@greptile-apps

greptile-apps Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[High impact] The routing change reaches outbound requests across many workspace workflows.

Correct the routing of operator-owned Azure requests before merging.

Findings

  1. P1 Azure requests take wrong route ▶
  2. P2 Gateway outages become connection errors ▶

Summary

Routes workspace-owned HTTP and customer AWS requests through organization gateways.

  • Workspace requests use their organization’s egress gateway when one is configured.
  • OAuth and credential requests leave through the workspace’s organization gateway.
  • Integration tools and webhook requests use the organization’s egress route.
  • Provider, knowledge, and data-drain requests follow the workspace’s egress route.
  • The unused OAuth token endpoint is removed.
  • A new audit flags workspace code that uses direct fetch without an explanation.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Authorized workspace or organization operation] --> B[Outbound scope]
  B --> C{Organization route}
  C -->|Direct| D[Existing fetch or AWS handler]
  C -->|Gateway| E[Organization-specific connection pool]
  E --> F[CONNECT gateway]
  F --> G[Vendor API]
  C -->|Blocked or unavailable| H[OutboundRoutingError]
  H --> I[Retryable outage response]
Loading

Reviews (1) · Last reviewed commit: "fix(egress): type the routed AWS handler..." · Reviewed by Greptile

): Promise<ProviderResponse | StreamingExecution> {
const { logger } = config
const fetchImpl = config.fetch ?? fetch
const fetchImpl = config.fetch ?? outboundFetch

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 Azure requests take wrong route

azureOpenAIProvider also calls this helper with fetch: undefined when using env.AZURE_OPENAI_ENDPOINT. The new default sends those operator-owned Azure requests through the customer's gateway. If the deployment is private or restricted to Sim's IPs, model requests now fail.

Keep the global fetch for operator-configured Azure endpoints while retaining routing for customer endpoints. The repository requires Sim's own infrastructure to stay direct.

Context Used: CLAUDE.md (source)

refreshed: result.refreshed,
}
}
if (result.outcome?.kind === 'egress_unavailable') throw result.outcome.error

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 Gateway outages become connection errors

The new egress_unavailable result is rethrown only by refreshTokenIfNeeded. Both getOAuthToken and resolveCredentialTokenBundle still turn every failed result into null. An expired Zoom token during a gateway outage therefore makes resolveSelectorOAuthAccessToken throw SelectorConnectionUnavailableError, which the selector route returns as 403.

Preserve the outage result through these callers too, so users see a retryable outage rather than a broken connection.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

17 issues found across 305 files

Confidence score: 2/5

  • Fireflies, Google Drive, and GitHub knowledge requests can send requests directly when their outbound scope is missing, bypassing the fail-closed behavior. Use scopedOutboundFetch in fireflies.ts, google-drive-errors.ts, and github-installation.ts.
  • credential-service.ts turns egress outages into generic token failures, and refresh followers can see a 401 while the leader gets a 503. Propagate egress_unavailable through the bundle and token paths and make the outcome visible to followers.
  • The OutboundRoutingError passthrough in aws-service-account.server.ts is wrapped by buildAwsServiceAccountSecret, so callers never receive it. Preserve the routing error through service-account-secret.ts.
  • Azure requests in openai/core.ts can go through the active organization’s gateway when the caller leaves fetch unset. Pass global fetch for operator-configured Azure endpoints.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/sim/lib/webhooks/polling/google-drive.ts">

<violation number="1" location="apps/sim/lib/webhooks/polling/google-drive.ts:214">
P2: A gateway or routing outage now throws from `outboundFetch`, and this poller's generic catch counts it with `markWebhookFailed`. After enough consecutive polls the Drive trigger is auto-disabled, even though the source is fine. Consider classifying `OutboundRoutingError` in this catch, or treating it like other source-fetch failures.</violation>
</file>

<file name="apps/sim/lib/oauth/credential-service.ts">

<violation number="1" location="apps/sim/lib/oauth/credential-service.ts:1265">
P2: Egress outages on the bundle and token paths still collapse to `null`, so these callers report a generic missing-token failure, not a 503. Propagate `egress_unavailable` through `resolveCredentialTokenBundle` and `getOAuthToken` by throwing the stored error, as `refreshTokenIfNeeded` now does. Also log the egress failure before returning, because the early return skips the leader's `logger.error`.</violation>

<violation number="2" location="apps/sim/lib/oauth/credential-service.ts:1856">
P2: A follower that waits on another process's refresh gets a generic 401 during an egress outage, while the leader gets the 503. The leader's `egress_unavailable` outcome is not visible to followers, so the same outage maps to two different responses. Have the follower path surface the egress outage, or map a null follower result to the egress error when the lease holder failed on routing.</violation>
</file>

<file name="apps/sim/lib/oauth/salesforce-instance.server.ts">

<violation number="1" location="apps/sim/lib/oauth/salesforce-instance.server.ts:18">
P2: This `catch` swallows gateway outage errors, so an org egress outage on the Salesforce identity lookup surfaces as a 502 `IDENTITY_UNAVAILABLE` instead of the 503 this PR maps egress outages to. Rethrow `OutboundRoutingError` here so the caller can map it to 503. The caller in `managed-oauth.ts` also needs to handle it.</violation>
</file>

<file name="apps/sim/lib/auth/auth.ts">

<violation number="1" location="apps/sim/lib/auth/auth.ts:521">
P2: The Salesforce identity lookup is now scoped in `account.create.before`, but the identical lookup in `account.create.after` still runs with no outbound scope. For orgs with a dedicated gateway, that request leaves from Sim's global IPs. Wrap the after-hook call with the same draft-scope resolution, or reuse the instance URL the before hook already stored.</violation>
</file>

<file name="apps/sim/lib/knowledge/access/github-installation.ts">

<violation number="1" location="apps/sim/lib/knowledge/access/github-installation.ts:75">
P2: This knowledge-connector GitHub read should use `scopedOutboundFetch`, not `outboundFetch`. Without an organization scope, `outboundFetch` silently goes direct, so the repository-authorization check can bypass the org's egress gateway. `secure-fetch.server.ts` already uses the fail-closed variant for knowledge fetches. Import and call `scopedOutboundFetch` here.</violation>
</file>

<file name="apps/sim/lib/data-drains/destinations/gcs.ts">

<violation number="1" location="apps/sim/lib/data-drains/destinations/gcs.ts:143">
P2: The GCS access token is still minted by `google-auth-library` through `JWT.getAccessToken()`, which bypasses `outboundFetch` and goes direct. Only the upload and delete calls use the org gateway, so the credential exchange is not covered by the egress policy. Pass a transporter that routes through `outboundFetch` into `buildJwt`, or call out this exclusion explicitly.</violation>
</file>

<file name="apps/sim/lib/credentials/token-service-accounts/errors.ts">

<violation number="1" location="apps/sim/lib/credentials/token-service-accounts/errors.ts:228">
P2: For gateway-routed organizations, an unresolvable user-supplied host no longer maps to the caller's `dnsFailureCode`. `connectThroughGateway` turns the DNS failure into `ROUTE_BLOCKED`, so `fetchProvider` returns `provider_unavailable` (503 retry) instead of `site_not_found`. Preserve the resolver error (`ENOTFOUND`) through the gateway connect path, or check the DNS refusal before mapping to `ROUTE_BLOCKED`.</violation>
</file>

<file name="apps/sim/lib/credentials/databricks-service-account.server.ts">

<violation number="1" location="apps/sim/lib/credentials/databricks-service-account.server.ts:166">
P2: This catch turns a gateway outage from `outboundFetch` into a 502 that blames the Databricks workspace. Rethrow `OutboundRoutingError` (or map it to a 503 `provider_unavailable`) before the generic 502, matching `fetchProvider` in `token-service-accounts/errors.ts`.</violation>
</file>

<file name="apps/sim/lib/internal/telegram/operations.ts">

<violation number="1" location="apps/sim/lib/internal/telegram/operations.ts:104">
P3: A gateway outage raised by `outboundFetch` here propagates past `sendTelegramDocument`'s handlers and reaches `executeTelegramTool`, which returns 500 for it. The PR maps egress outages to 503, so map `OutboundRoutingError` (GATEWAY_UNAVAILABLE) to 503 in `execute-tool.ts`, or wrap this call.</violation>
</file>

<file name="apps/sim/lib/credentials/microsoft-app-service-account.server.ts">

<violation number="1" location="apps/sim/lib/credentials/microsoft-app-service-account.server.ts:418">
P2: An egress outage from `outboundFetch` is caught in `requestEntraToken` and rethrown as a 502 "Could not reach Microsoft Entra" error. The other egress handlers in this PR map `OutboundRoutingError` to 503. Rethrow `OutboundRoutingError` unchanged, or map it to 503, so it matches that contract.</violation>
</file>

<file name="apps/sim/lib/credentials/aws-service-account.server.ts">

<violation number="1" location="apps/sim/lib/credentials/aws-service-account.server.ts:285">
P2: The new `OutboundRoutingError` passthrough here never reaches the caller. `buildAwsServiceAccountSecret` in `service-account-secret.ts` wraps every error except `AwsCredentialError` as a `ServiceAccountSecretError`, and credential create maps that to a 400. The pin probe in `assertRolePinsSubject` has the same gap: it wraps the error into an `AwsCredentialError`. Let egress errors pass through both catches so they reach the 503 mapping.</violation>
</file>

<file name="apps/sim/lib/credential-groups/standard-oauth-provider.ts">

<violation number="1" location="apps/sim/lib/credential-groups/standard-oauth-provider.ts:319">
P2: The new 503 mapping covers only the code exchange. `managed.verifyIdentity` also performs `outboundFetch` calls, but its catch still converts gateway outages to 502; preserve `OutboundRoutingError` as a 503 there too.</violation>
</file>

<file name="apps/sim/connectors/fireflies/fireflies.ts">

<violation number="1" location="apps/sim/connectors/fireflies/fireflies.ts:204">
P1: Fireflies knowledge-sync requests use the fallback `outboundFetch`, allowing a missing scope to send the customer token directly. Use `scopedOutboundFetch` here so connector scope loss remains fail-closed.</violation>
</file>

<file name="apps/sim/app/api/wand/route.ts">

<violation number="1" location="apps/sim/app/api/wand/route.ts:374">
P2: Egress failures from `postWandRequest` are returned as HTTP 500, not 503. Map `OutboundRoutingError` gateway/configuration failures to 503 in both the streaming and non-streaming error paths so gateway outages remain retryable and are not treated as ordinary generation failures.</violation>
</file>

<file name="apps/sim/connectors/google-drive/google-drive-errors.ts">

<violation number="1" location="apps/sim/connectors/google-drive/google-drive-errors.ts:173">
P1: Google Drive knowledge requests now use the fallback `outboundFetch`, so a missing outbound scope is downgraded to a direct request instead of failing closed. Use `scopedOutboundFetch` for this connector path (or pass it explicitly) so scope loss cannot bypass organization egress.</violation>
</file>

<file name="apps/sim/providers/openai/core.ts">

<violation number="1" location="apps/sim/providers/openai/core.ts:160">
P2: Pass global `fetch` for operator-configured Azure endpoints; this default routes them through the active organization's gateway when the Azure caller leaves `fetch` unset.</violation>
</file>

Note: This PR contains a large number of files. cubic selects up to 200 of the highest-priority eligible files for this review, so some files may not have been reviewed.

Turn on auto-fix | Re-trigger cubic

async () => {
/** One retry layer owns transport, HTTP, and GraphQL semantic failures. */
const response = await fetch(FIREFLIES_GRAPHQL_URL, {
const response = await outboundFetch(FIREFLIES_GRAPHQL_URL, {

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: Fireflies knowledge-sync requests use the fallback outboundFetch, allowing a missing scope to send the customer token directly. Use scopedOutboundFetch here so connector scope loss remains fail-closed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/connectors/fireflies/fireflies.ts, line 204:

<comment>Fireflies knowledge-sync requests use the fallback `outboundFetch`, allowing a missing scope to send the customer token directly. Use `scopedOutboundFetch` here so connector scope loss remains fail-closed.</comment>

<file context>
@@ -200,7 +201,7 @@ async function firefliesGraphQL(
     async () => {
       /** One retry layer owns transport, HTTP, and GraphQL semantic failures. */
-      const response = await fetch(FIREFLIES_GRAPHQL_URL, {
+      const response = await outboundFetch(FIREFLIES_GRAPHQL_URL, {
         method: 'POST',
         headers: {
</file context>
Fix with cubic

return retryWithExponentialBackoff(
async () => {
const response = await fetch(url, options)
const response = await outboundFetch(url, options)

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: Google Drive knowledge requests now use the fallback outboundFetch, so a missing outbound scope is downgraded to a direct request instead of failing closed. Use scopedOutboundFetch for this connector path (or pass it explicitly) so scope loss cannot bypass organization egress.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/connectors/google-drive/google-drive-errors.ts, line 173:

<comment>Google Drive knowledge requests now use the fallback `outboundFetch`, so a missing outbound scope is downgraded to a direct request instead of failing closed. Use `scopedOutboundFetch` for this connector path (or pass it explicitly) so scope loss cannot bypass organization egress.</comment>

<file context>
@@ -169,7 +170,7 @@ export async function fetchGoogleDriveWithRetry(
   return retryWithExponentialBackoff(
     async () => {
-      const response = await fetch(url, options)
+      const response = await outboundFetch(url, options)
       if (response.ok) return response
 
</file context>
Fix with cubic


const url = `${DRIVE_API_BASE}/changes/startPageToken?${params.toString()}`
const response = await fetch(url, {
const response = await outboundFetch(url, {

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: A gateway or routing outage now throws from outboundFetch, and this poller's generic catch counts it with markWebhookFailed. After enough consecutive polls the Drive trigger is auto-disabled, even though the source is fine. Consider classifying OutboundRoutingError in this catch, or treating it like other source-fetch failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/webhooks/polling/google-drive.ts, line 214:

<comment>A gateway or routing outage now throws from `outboundFetch`, and this poller's generic catch counts it with `markWebhookFailed`. After enough consecutive polls the Drive trigger is auto-disabled, even though the source is fine. Consider classifying `OutboundRoutingError` in this catch, or treating it like other source-fetch failures.</comment>

<file context>
@@ -210,7 +211,7 @@ async function getStartPageToken(
 
   const url = `${DRIVE_API_BASE}/changes/startPageToken?${params.toString()}`
-  const response = await fetch(url, {
+  const response = await outboundFetch(url, {
     headers: { Authorization: `Bearer ${accessToken}` },
   })
</file context>
Fix with cubic

logger.info('Successfully refreshed access token', logContext)
return { kind: 'token', accessToken: result.accessToken }
} catch (error) {
if (error instanceof OutboundRoutingError) return { kind: 'egress_unavailable', error }

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: A follower that waits on another process's refresh gets a generic 401 during an egress outage, while the leader gets the 503. The leader's egress_unavailable outcome is not visible to followers, so the same outage maps to two different responses. Have the follower path surface the egress outage, or map a null follower result to the egress error when the lease holder failed on routing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/oauth/credential-service.ts, line 1856:

<comment>A follower that waits on another process's refresh gets a generic 401 during an egress outage, while the leader gets the 503. The leader's `egress_unavailable` outcome is not visible to followers, so the same outage maps to two different responses. Have the follower path surface the egress outage, or map a null follower result to the egress error when the lease holder failed on routing.</comment>

<file context>
@@ -1850,6 +1853,7 @@ async function performCoalescedRefresh({
             logger.info('Successfully refreshed access token', logContext)
             return { kind: 'token', accessToken: result.accessToken }
           } catch (error) {
+            if (error instanceof OutboundRoutingError) return { kind: 'egress_unavailable', error }
             logger.error('Refresh failed inside leader path', {
               ...logContext,
</file context>
Fix with cubic

// A keyless refusal already names its cause and code; wrapping it would lose both.
if (error instanceof KeylessCredentialRefusedError) return error
// A refused or unavailable egress route says nothing about the credential.
if (error instanceof OutboundRoutingError) return error

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: The new OutboundRoutingError passthrough here never reaches the caller. buildAwsServiceAccountSecret in service-account-secret.ts wraps every error except AwsCredentialError as a ServiceAccountSecretError, and credential create maps that to a 400. The pin probe in assertRolePinsSubject has the same gap: it wraps the error into an AwsCredentialError. Let egress errors pass through both catches so they reach the 503 mapping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/credentials/aws-service-account.server.ts, line 285:

<comment>The new `OutboundRoutingError` passthrough here never reaches the caller. `buildAwsServiceAccountSecret` in `service-account-secret.ts` wraps every error except `AwsCredentialError` as a `ServiceAccountSecretError`, and credential create maps that to a 400. The pin probe in `assertRolePinsSubject` has the same gap: it wraps the error into an `AwsCredentialError`. Let egress errors pass through both catches so they reach the 503 mapping.</comment>

<file context>
@@ -280,6 +281,8 @@ function bindSubject(
   // A keyless refusal already names its cause and code; wrapping it would lose both.
   if (error instanceof KeylessCredentialRefusedError) return error
+  // A refused or unavailable egress route says nothing about the credential.
+  if (error instanceof OutboundRoutingError) return error
   const detail = failureClause(error)
   if (config.authType === 'oidc_role') {
</file context>
Fix with cubic

})
} catch {
} catch (error) {
if (error instanceof OutboundRoutingError) {

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: The new 503 mapping covers only the code exchange. managed.verifyIdentity also performs outboundFetch calls, but its catch still converts gateway outages to 502; preserve OutboundRoutingError as a 503 there too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/credential-groups/standard-oauth-provider.ts, line 319:

<comment>The new 503 mapping covers only the code exchange. `managed.verifyIdentity` also performs `outboundFetch` calls, but its catch still converts gateway outages to 502; preserve `OutboundRoutingError` as a 503 there too.</comment>

<file context>
@@ -306,14 +308,20 @@ export function createStandardOAuthCredentialGroupProviderAdapter(
         })
-      } catch {
+      } catch (error) {
+        if (error instanceof OutboundRoutingError) {
+          throw new CredentialGroupOAuthError(
+            `${service.name} authorization is temporarily unavailable.`,
</file context>
Fix with cubic

Comment thread apps/sim/app/api/wand/route.ts Outdated
}

const response = await fetch(apiUrl, {
const response = await postWandRequest(workspaceId, {

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: Egress failures from postWandRequest are returned as HTTP 500, not 503. Map OutboundRoutingError gateway/configuration failures to 503 in both the streaming and non-streaming error paths so gateway outages remain retryable and are not treated as ordinary generation failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/app/api/wand/route.ts, line 374:

<comment>Egress failures from `postWandRequest` are returned as HTTP 500, not 503. Map `OutboundRoutingError` gateway/configuration failures to 503 in both the streaming and non-streaming error paths so gateway outages remain retryable and are not treated as ordinary generation failures.</comment>

<file context>
@@ -342,26 +356,22 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
-        }
-
-        const response = await fetch(apiUrl, {
+        const response = await postWandRequest(workspaceId, {
           method: 'POST',
           headers,
</file context>
Fix with cubic

): Promise<ProviderResponse | StreamingExecution> {
const { logger } = config
const fetchImpl = config.fetch ?? fetch
const fetchImpl = config.fetch ?? outboundFetch

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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: Pass global fetch for operator-configured Azure endpoints; this default routes them through the active organization's gateway when the Azure caller leaves fetch unset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/providers/openai/core.ts, line 160:

<comment>Pass global `fetch` for operator-configured Azure endpoints; this default routes them through the active organization's gateway when the Azure caller leaves `fetch` unset.</comment>

<file context>
@@ -156,7 +157,7 @@ export async function executeResponsesProviderRequest(
 ): Promise<ProviderResponse | StreamingExecution> {
   const { logger } = config
-  const fetchImpl = config.fetch ?? fetch
+  const fetchImpl = config.fetch ?? outboundFetch
 
   logger.info(`Preparing ${config.providerLabel} request`, {
</file context>
Fix with cubic

}

const response = await fetch(
const response = await outboundFetch(

@cubic-dev-ai cubic-dev-ai Bot Nov 14, 2026 •

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.

P3: A gateway outage raised by outboundFetch here propagates past sendTelegramDocument's handlers and reaches executeTelegramTool, which returns 500 for it. The PR maps egress outages to 503, so map OutboundRoutingError (GATEWAY_UNAVAILABLE) to 503 in execute-tool.ts, or wrap this call.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/internal/telegram/operations.ts, line 104:

<comment>A gateway outage raised by `outboundFetch` here propagates past `sendTelegramDocument`'s handlers and reaches `executeTelegramTool`, which returns 500 for it. The PR maps egress outages to 503, so map `OutboundRoutingError` (GATEWAY_UNAVAILABLE) to 503 in `execute-tool.ts`, or wrap this call.</comment>

<file context>
@@ -100,7 +101,7 @@ export async function sendTelegramDocument(
   }
 
-  const response = await fetch(
+  const response = await outboundFetch(
     `https://api.telegram.org/bot${encodeURIComponent(input.botToken)}/sendDocument`,
     { method: 'POST', body: form, signal: context.signal }
</file context>
Fix with cubic

…ken route removal

resolveCredentialAccessToken now takes the execution's userId and builds the
internal-JWT auth itself instead of an authenticate callback; resolveCredentialToken
is private. Removes the session/API-key saved-secret refusal no caller can reach,
the auditRequest plumbing the executor never sets (recordCredentialAccess keeps it
for selectors), and the unparsed oauthTokenResponseSchema in favor of a plain
CredentialTokenPayload type.
…n unavailable orchestration error

Add an 'unavailable' OrchestrationErrorCode (503, v2 SERVICE_UNAVAILABLE) and
let asOrchestrationError classify an OutboundRoutingError anywhere in the cause
chain with a fixed message, so no routing code reaches callers. The standard
OAuth credential-group adapter now rethrows routing failures from code exchange
and identity verification instead of wrapping them locally.
…le code in token resolution, managed OAuth and Slack
…orized use cases

The wand route and the baseten, fireworks, together and ollama-cloud model
routes authorized inline and set the outbound scope per call through
fetchForWorkspace. Each now authenticates, parses and presents only; the
use case loads the workspace, authorizes, picks the BYOK or Sim key and
calls the vendor with scopedOutboundFetch inside the workspace's outbound
scope. Wand splits into a workflow operation (write) and a workspace
operation (read) so each declares its real minimum role. A model listing
for a caller outside the workspace still falls back to Sim's key, direct.
fetchForWorkspace has no callers left and is removed.

This branch was previously deployed

1 inactive (outdated) deployment
Preview — 388d2821 Deployed Nov 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant