diff --git a/packages/connections/src/connected-hook.test.ts b/packages/connections/src/connected-hook.test.ts index f983275b8..8cbd20521 100644 --- a/packages/connections/src/connected-hook.test.ts +++ b/packages/connections/src/connected-hook.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { + fireConnectedHook, fireInferenceCredentialSeedableHook, type InferenceCredentialSeedableInfo, + type ServiceConnectedInfo, } from "./connected-hook"; function seedableInfo(): InferenceCredentialSeedableInfo { @@ -15,6 +18,46 @@ function seedableInfo(): InferenceCredentialSeedableInfo { }; } +function connectedInfo(): ServiceConnectedInfo { + return { + tenantId: "tenant_1", + principalId: "principal_1", + connectorId: "github", + displayName: "GitHub", + }; +} + +describe("fireConnectedHook", () => { + test("does nothing when no hook is wired", async () => { + await expect( + fireConnectedHook(undefined, () => {}, connectedInfo()), + ).resolves.toBeUndefined(); + }); + + test("logs and reports a hook failure rather than breaking the connect", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const logged: string[] = []; + await expect( + fireConnectedHook( + () => { + throw new Error("card settle unavailable"); + }, + (line) => logged.push(line), + connectedInfo(), + ), + ).resolves.toBeUndefined(); + expect(logged).toHaveLength(1); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "fire_connected_hook", + tenantId: "tenant_1", + extra: { connectorId: "github" }, + }); + report.mockRestore(); + }); +}); + describe("fireInferenceCredentialSeedableHook", () => { test("does nothing when no hook is wired", async () => { await expect( @@ -34,7 +77,8 @@ describe("fireInferenceCredentialSeedableHook", () => { expect(calls).toEqual([seedableInfo()]); }); - test("logs and swallows a hook failure rather than breaking the connect", async () => { + test("logs and reports a hook failure rather than breaking the connect", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const logged: string[] = []; await expect( fireInferenceCredentialSeedableHook( @@ -48,5 +92,13 @@ describe("fireInferenceCredentialSeedableHook", () => { expect(logged).toHaveLength(1); expect(logged[0]).toContain("tenant_1"); expect(logged[0]).toContain("drain unavailable"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "fire_inference_credential_seedable_hook", + tenantId: "tenant_1", + extra: { provider: "ollama" }, + }); + report.mockRestore(); }); }); diff --git a/packages/connections/src/connected-hook.ts b/packages/connections/src/connected-hook.ts index 8ffd4dd42..3b3372892 100644 --- a/packages/connections/src/connected-hook.ts +++ b/packages/connections/src/connected-hook.ts @@ -5,6 +5,7 @@ // without knowing which door the connection came through. Best-effort // by contract: a hook failure is logged and never breaks the connect // itself — the credential is already stored when this fires. +import { reportError } from "@corbits/error-sink"; export type ServiceConnectedInfo = { readonly tenantId: string; @@ -30,6 +31,11 @@ export async function fireConnectedHook( log( `onConnected hook failed for ${info.connectorId} on tenant ${info.tenantId}: ${message}`, ); + reportError(cause, { + operation: "fire_connected_hook", + tenantId: info.tenantId, + extra: { connectorId: info.connectorId }, + }); } } @@ -77,5 +83,10 @@ export async function fireInferenceCredentialSeedableHook( log( `onInferenceCredentialUsable hook failed for ${info.provider} on tenant ${info.tenantId}: ${message}`, ); + reportError(cause, { + operation: "fire_inference_credential_seedable_hook", + tenantId: info.tenantId, + extra: { provider: info.provider }, + }); } } diff --git a/packages/connections/src/github-connect.test.ts b/packages/connections/src/github-connect.test.ts index 9b4ce47ab..553858d09 100644 --- a/packages/connections/src/github-connect.test.ts +++ b/packages/connections/src/github-connect.test.ts @@ -3,7 +3,8 @@ // surface GitHub's own `error`/`error_description` shape (distinct from // a non-2xx status — GitHub's token endpoint answers 200 with an `error` // field on a rejected code). -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { exchangeCodeForGithubToken, type ExchangeFetch, @@ -101,6 +102,7 @@ describe("exchangeCodeForGithubToken", () => { }); test("a transport failure is reported honestly", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const fetchImpl: ExchangeFetch = async () => { throw new Error("getaddrinfo ENOTFOUND"); }; @@ -118,5 +120,11 @@ describe("exchangeCodeForGithubToken", () => { expect(result.message).toContain("Could not reach GitHub"); expect(result.message).toContain("getaddrinfo ENOTFOUND"); } + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_github_token", + }); + report.mockRestore(); }); }); diff --git a/packages/connections/src/github-connect.ts b/packages/connections/src/github-connect.ts index 0ce570c0f..9f1d7e18c 100644 --- a/packages/connections/src/github-connect.ts +++ b/packages/connections/src/github-connect.ts @@ -12,6 +12,7 @@ // still names `credentialPlugin: "http"`). import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; export const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; export const GITHUB_TOKEN_EXCHANGE_URL = @@ -69,6 +70,7 @@ export async function exchangeCodeForGithubToken( }), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_github_token" }); return { ok: false, message: diff --git a/packages/connections/src/gmail-connect.test.ts b/packages/connections/src/gmail-connect.test.ts index ca59f32bb..3a7b64bdd 100644 --- a/packages/connections/src/gmail-connect.test.ts +++ b/packages/connections/src/gmail-connect.test.ts @@ -2,7 +2,8 @@ // entirely against a stubbed fetch — no Google credentials involved. // Live-key verification against a real Google OAuth app is a deploy // concern (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET`), not a test one. -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { exchangeCodeForGoogleToken, @@ -89,3 +90,25 @@ test("a Google error response maps to an honest failure that never echoes token expect(result.message).toContain("invalid_grant"); expect(result.message).not.toContain("secret-1"); }); + +test("a transport failure is reported and never crashes", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const result = await exchangeCodeForGoogleToken({ + code: "auth-code-1", + redirectUri: "https://bench.example.com/callback", + clientId: "client-1", + clientSecret: "secret-1", + fetchImpl: async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected failure"); + expect(result.message).toContain("getaddrinfo ENOTFOUND"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_google_token", + }); + report.mockRestore(); +}); diff --git a/packages/connections/src/gmail-connect.ts b/packages/connections/src/gmail-connect.ts index b337fbf19..11043cbe6 100644 --- a/packages/connections/src/gmail-connect.ts +++ b/packages/connections/src/gmail-connect.ts @@ -11,6 +11,7 @@ // authorize URL) for the credential row to keep alongside the secret. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; export const GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -83,6 +84,7 @@ export async function exchangeCodeForGoogleToken( body: params.toString(), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_google_token" }); const message = cause instanceof Error ? cause.message : String(cause); return { ok: false, message: `Google token exchange failed: ${message}` }; } diff --git a/packages/connections/src/huggingface-connect.test.ts b/packages/connections/src/huggingface-connect.test.ts new file mode 100644 index 000000000..3736de24a --- /dev/null +++ b/packages/connections/src/huggingface-connect.test.ts @@ -0,0 +1,61 @@ +// The exchange's own contract: parse Hugging Face's response at the trust +// boundary without ever putting token material in a failure message, and +// route a transport failure through reportError. +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; +import { + exchangeCodeForToken, + type ExchangeFetch, +} from "./huggingface-connect"; + +describe("exchangeCodeForToken", () => { + test("trades the code and verifier for an access token", async () => { + const fetchImpl: ExchangeFetch = async () => + new Response( + JSON.stringify({ access_token: "hf_minted_token", expires_in: 3600 }), + { status: 200 }, + ); + + const result = await exchangeCodeForToken({ + code: "auth_code_1", + codeVerifier: "verifier_1", + redirectUri: "https://hub.example.test/callback", + clientId: "client_1", + fetchImpl, + now: () => 0, + }); + + expect(result).toEqual({ + ok: true, + accessToken: "hf_minted_token", + expiresAt: new Date(3600 * 1000).toISOString(), + }); + }); + + test("a transport failure is reported, never token material", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const fetchImpl: ExchangeFetch = async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }; + + const result = await exchangeCodeForToken({ + code: "auth_code_1", + codeVerifier: "verifier_1", + redirectUri: "https://hub.example.test/callback", + clientId: "client_1", + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain("Could not reach Hugging Face"); + expect(result.message).toContain("getaddrinfo ENOTFOUND"); + } + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_huggingface_token", + }); + report.mockRestore(); + }); +}); diff --git a/packages/connections/src/huggingface-connect.ts b/packages/connections/src/huggingface-connect.ts index 977d0b1df..b3c53d1f5 100644 --- a/packages/connections/src/huggingface-connect.ts +++ b/packages/connections/src/huggingface-connect.ts @@ -14,6 +14,7 @@ // later expiry sweep reads. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; export const HUGGINGFACE_AUTHORIZE_URL = "https://huggingface.co/oauth/authorize"; @@ -90,6 +91,7 @@ export async function exchangeCodeForToken( body: body.toString(), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_huggingface_token" }); return { ok: false, message: diff --git a/packages/connections/src/mcp-oauth-routes.test.ts b/packages/connections/src/mcp-oauth-routes.test.ts index 90c6c75bd..0e2fab7c1 100644 --- a/packages/connections/src/mcp-oauth-routes.test.ts +++ b/packages/connections/src/mcp-oauth-routes.test.ts @@ -8,7 +8,8 @@ // `deps.probe` (the same test seam `mcp-server-routes.test.ts` uses), // since this suite is about the OAuth mechanics, not a second proof that // `@corbits/mcp-tools`' transport works. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono, type MiddlewareHandler } from "hono"; import { createNoopCredentialCipher } from "@intx/crypto"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -621,6 +622,7 @@ describe("MCP OAuth connect flow", () => { }); test("start redirects discovery_failed when the authorization server is unreachable", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const hub = fakeHub(); const routes = createMcpOAuthRoutes({ hubUrl: "http://hub.test", @@ -638,6 +640,13 @@ describe("MCP OAuth connect flow", () => { expect(response.headers.get("location")).toBe( "/plugins?mcpOauth=canva&outcome=error&code=discovery_failed", ); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "mcp_oauth_start", + extra: { slug: "canva" }, + }); + report.mockRestore(); }); test("callback completes the token exchange and stores a bearer credential", async () => { @@ -677,6 +686,106 @@ describe("MCP OAuth connect flow", () => { } }); + test("a token-exchange failure is reported without leaking the code or verifier", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const as = startStubAuthorizationServer(); + try { + const hub = fakeHub(); + const routes = createMcpOAuthRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + credentialCipher: createNoopCredentialCipher(), + apiCall: hub.apiCall, + }); + const app = mountAs(routes); + + const startResponse = await app.request( + `/exa/start?url=${encodeURIComponent(as.resourcePath)}&name=Exa`, + { redirect: "manual" }, + ); + const cookieHeader = startResponse.headers.get("set-cookie") ?? ""; + const cookie = cookieHeader.split(";")[0] ?? ""; + const authorizeLocation = startResponse.headers.get("location") ?? ""; + const authorizeResponse = await fetch(authorizeLocation, { + redirect: "manual", + }); + const redirectToCallback = + authorizeResponse.headers.get("location") ?? ""; + const callbackUrl = new URL(redirectToCallback); + // A code the stub server never issued: its /token endpoint 400s + // with invalid_grant, which auth() surfaces as a throw. + callbackUrl.searchParams.set("code", "code_never_issued"); + + const callbackResponse = await app.request( + `${callbackUrl.pathname}${callbackUrl.search}`, + { headers: { cookie }, redirect: "manual" }, + ); + + expect(callbackResponse.headers.get("location") ?? "").toContain( + "code=exchange_failed", + ); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "mcp_oauth_token_exchange", + tenantId: TENANT.id, + extra: { slug: "exa" }, + }); + report.mockRestore(); + } finally { + as.stop(); + } + }); + + test("a persist failure after a completed exchange is reported without leaking the token", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const as = startStubAuthorizationServer(); + try { + const failingApiCall: ApiCall = async (method, path) => { + if (method === "GET" && path.endsWith("/providers?inherited=false")) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + throw new Error("provider store unavailable"); + }; + const routes = createMcpOAuthRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + credentialCipher: createNoopCredentialCipher(), + apiCall: failingApiCall, + probe: async (): Promise => ({ + ok: true, + toolCount: 1, + }), + }); + const app = mountAs(routes); + + const callbackResponse = await runConnectFlow(app, as); + + expect(callbackResponse.headers.get("location") ?? "").toContain( + "code=setup_failed", + ); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_mcp_oauth_connection", + tenantId: TENANT.id, + extra: { slug: "exa" }, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("token_for_"); + report.mockRestore(); + } finally { + as.stop(); + } + }); + test("callback stores an oauth_token credential with the issued refresh token and expiry", async () => { const as = startStubAuthorizationServer({ refreshToken: "refresh_abc123", diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index 2ea0f4dfa..998e6e43b 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -35,6 +35,7 @@ import { createConnectStateStore, randomToken } from "./pkce"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; import { mcpPresetBySlug } from "./mcp-presets"; import { probeMcpServer, type McpProbeResult } from "./mcp-probe"; +import { reportError } from "@corbits/error-sink"; import { listMcpProviders, providerName, @@ -159,7 +160,10 @@ async function fetchCapturingOAuthError( captured.code = code; } } catch { - // malformed JSON on an error response; classifier uses the thrown error + // report-error-ignore: CL-7247 — malformed JSON on an error response; + // mcpOAuthStartErrorCode's own message-sniffing fallback classifies + // the thrown error regardless, so this degrades to that path rather + // than losing information worth reporting on its own. } return response; } @@ -298,6 +302,11 @@ export function createMcpOAuthRoutes( } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); deps.log(`mcp oauth start failed for "${target.slug}": ${message}`); + reportError(cause, { + operation: "mcp_oauth_start", + tenantId: c.get("tenant").id, + extra: { slug: target.slug }, + }); return c.redirect( redirectPath(returnPath, { mcpOauth: target.slug, @@ -459,6 +468,13 @@ export function createMcpOAuthRoutes( deps.log( `mcp oauth token exchange failed for "${payload.slug}": ${message}`, ); + // Never widen extra beyond identifiers safe to print — the + // authorization `code` and any codeVerifier are in scope above. + reportError(cause, { + operation: "mcp_oauth_token_exchange", + tenantId: c.get("tenant").id, + extra: { slug: payload.slug }, + }); return c.redirect( redirectPath(returnPath, { mcpOauth: payload.slug, @@ -583,6 +599,13 @@ export function createMcpOAuthRoutes( deps.log( `mcp oauth connect setup failed for tenant ${tenant.id}, slug ${payload.slug}: ${message}`, ); + // Never widen extra beyond identifiers safe to print — the + // exchanged access/refresh tokens are in scope above. + reportError(cause, { + operation: "persist_mcp_oauth_connection", + tenantId: tenant.id, + extra: { slug: payload.slug }, + }); return c.redirect( redirectPath(returnPath, { mcpOauth: payload.slug, diff --git a/packages/connections/src/mcp-oauth.test.ts b/packages/connections/src/mcp-oauth.test.ts index 847b4c988..c246b5980 100644 --- a/packages/connections/src/mcp-oauth.test.ts +++ b/packages/connections/src/mcp-oauth.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { createMcpOAuthProvider } from "./mcp-oauth"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; +import { createMcpOAuthProvider, refreshMcpOAuthTokens } from "./mcp-oauth"; describe("createMcpOAuthProvider", () => { test("clientMetadata includes scope only when it is passed", () => { @@ -34,3 +35,28 @@ describe("createMcpOAuthProvider", () => { }); }); }); + +describe("refreshMcpOAuthTokens", () => { + test("an unreachable server reports the failure and comes back ok: false", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const server = Bun.serve({ port: 0, fetch: () => new Response(null) }); + const serverUrl = `http://127.0.0.1:${String(server.port)}/mcp`; + server.stop(true); + + const result = await refreshMcpOAuthTokens({ + serverUrl, + tokens: { access_token: "at_1", token_type: "Bearer" }, + callbackUrl: "http://hub.test/callback", + clientName: "Corbits Workbench", + }); + + expect(result.ok).toBe(false); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "refresh_mcp_oauth_tokens", + extra: { serverUrl }, + }); + report.mockRestore(); + }); +}); diff --git a/packages/connections/src/mcp-oauth.ts b/packages/connections/src/mcp-oauth.ts index 53a9eceed..7d6ef7045 100644 --- a/packages/connections/src/mcp-oauth.ts +++ b/packages/connections/src/mcp-oauth.ts @@ -24,6 +24,7 @@ import type { OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { reportError } from "@corbits/error-sink"; export type McpOAuthSession = { clientInformation?: OAuthClientInformationMixed; @@ -149,6 +150,10 @@ export async function refreshMcpOAuthTokens(args: { try { result = await auth(provider, { serverUrl: args.serverUrl }); } catch (cause) { + reportError(cause, { + operation: "refresh_mcp_oauth_tokens", + extra: { serverUrl: args.serverUrl }, + }); return { ok: false, message: cause instanceof Error ? cause.message : String(cause), diff --git a/packages/connections/src/mcp-probe.test.ts b/packages/connections/src/mcp-probe.test.ts index c5d7a8e20..82d472142 100644 --- a/packages/connections/src/mcp-probe.test.ts +++ b/packages/connections/src/mcp-probe.test.ts @@ -3,7 +3,8 @@ // metadata behind it, and a 401 that does advertise RFC 9728/8414 metadata // (the OAuth-gated shape this probe is meant to recognize). Unpinned fetch // follows a 302 to another origin; the probe must not. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { probeMcpServer } from "./mcp-probe"; function startUnauthorizedServer(): { url: string; stop: () => void } { @@ -61,17 +62,24 @@ describe("probeMcpServer", () => { }); test("a plain 401 with no discoverable OAuth metadata reports a plain failure", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const stub = startUnauthorizedServer(); try { const result = await probeMcpServer(stub.url, undefined); expect(result.ok).toBe(false); if (!result.ok) expect(result.requiresOAuth).toBeUndefined(); + expect(report).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ operation: "probe_mcp_server" }), + ); } finally { stub.stop(); + report.mockRestore(); } }); test("a 401 backed by RFC 9728/8414 metadata reports requiresOAuth with the discovered authorization server", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const stub = startOAuthGatedServer(); try { const result = await probeMcpServer(stub.url, undefined); @@ -81,8 +89,13 @@ describe("probeMcpServer", () => { } else { throw new Error("expected requiresOAuth: true"); } + expect(report).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ operation: "probe_mcp_server" }), + ); } finally { stub.stop(); + report.mockRestore(); } }); diff --git a/packages/connections/src/mcp-probe.ts b/packages/connections/src/mcp-probe.ts index 187d56e85..32a24fce2 100644 --- a/packages/connections/src/mcp-probe.ts +++ b/packages/connections/src/mcp-probe.ts @@ -8,6 +8,7 @@ import { mcpOriginPinnedFetch } from "@corbits/credential-providers"; import { withMcpConnection, listMcpTools } from "@corbits/mcp-tools"; import { discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js"; +import { reportError } from "@corbits/error-sink"; export type McpProbeResult = | { readonly ok: true; readonly toolCount: number } @@ -47,7 +48,15 @@ async function discoverOAuthRequirement( return info.authorizationServerMetadata !== undefined ? { authorizationServerUrl: info.authorizationServerUrl } : undefined; - } catch { + } catch (cause) { + // A missing discovery document is the expected negative result (most + // MCP servers aren't OAuth-gated) and degrades to it identically to a + // real discovery-transport failure — report so an actual outage here + // doesn't silently read as "this server doesn't do OAuth." + reportError(cause, { + operation: "discover_mcp_oauth_requirement", + extra: { origin: new URL(url).origin }, + }); return undefined; } } @@ -78,6 +87,10 @@ export async function probeMcpServer( try { parsedUrl = new URL(url); } catch { + // report-error-ignore: CL-7247 — a malformed URL here is a person's + // paste-in typo (the same "not a valid URL" outcome the UI already + // surfaces to them), never a system fault; there is nothing to fix in + // response to it. return { ok: false, message: `"${url}" is not a valid URL.` }; } if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { @@ -95,6 +108,10 @@ export async function probeMcpServer( cause instanceof Error ? `Could not connect to that MCP server: ${cause.message}` : `Could not connect to that MCP server: ${String(cause)}`; + reportError(cause, { + operation: "probe_mcp_server", + extra: { origin: parsedUrl.origin }, + }); if (looksUnauthorized(cause)) { const discovered = await discoverOAuthRequirement(url); if (discovered !== undefined) { diff --git a/packages/connections/src/mcp-server-routes.test.ts b/packages/connections/src/mcp-server-routes.test.ts index 9190646a5..4b5097d92 100644 --- a/packages/connections/src/mcp-server-routes.test.ts +++ b/packages/connections/src/mcp-server-routes.test.ts @@ -2,7 +2,8 @@ // (`apiCall`) and a stubbed probe, mirroring `routes.test.ts`'s own // bare-Hono/tenant-injecting-middleware setup. Nothing here touches the // real network or a real MCP server. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -241,6 +242,42 @@ describe("POST /", () => { expect(body.slug).toBe("notion-2"); }); + test("a storage failure after a successful probe reports it without leaking the token", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const failingApiCall: ApiCall = async (method, path) => { + if (method === "GET" && path.endsWith("/providers?inherited=false")) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + throw new Error("provider store unavailable"); + }; + const app = buildApp({ apiCall: failingApiCall }); + + const response = await app.request("/", { + method: "POST", + body: JSON.stringify({ + name: "Notion Workspace", + url: "https://mcp.notion.example/sse", + token: "secret-token", + }), + }); + + expect(response.status).toBe(500); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_mcp_server_connection", + tenantId: TENANT.id, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("secret-token"); + report.mockRestore(); + }); + test("a failing probe never touches storage", async () => { const hub = fakeHub({}); const app = buildApp({ diff --git a/packages/connections/src/mcp-server-routes.ts b/packages/connections/src/mcp-server-routes.ts index 48b4e0527..8765e260f 100644 --- a/packages/connections/src/mcp-server-routes.ts +++ b/packages/connections/src/mcp-server-routes.ts @@ -34,6 +34,7 @@ import { } from "@corbits/credential-providers"; import { probeMcpServer } from "./mcp-probe"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; +import { reportError } from "@corbits/error-sink"; import { MCP_PRESETS, mcpPresetBySlug } from "./mcp-presets"; const ErrorEnvelope = (code: string, message: string) => ({ @@ -342,6 +343,13 @@ export function createMcpServerRoutes( deps.log( `mcp server connect failed for tenant ${tenant.id}, slug ${slug}: ${message}`, ); + // Never widen extra beyond identifiers safe to print — `cause` here + // can carry the pasted bearer token in scope above. + reportError(cause, { + operation: "persist_mcp_server_connection", + tenantId: tenant.id, + extra: { slug }, + }); return c.json( ErrorEnvelope( "connection_setup_failed", diff --git a/packages/connections/src/oauth-routes.test.ts b/packages/connections/src/oauth-routes.test.ts index af364b416..2d113add7 100644 --- a/packages/connections/src/oauth-routes.test.ts +++ b/packages/connections/src/oauth-routes.test.ts @@ -6,7 +6,8 @@ // themselves: state sealing/consuming, PKCE round-tripping, the // returnPath cookie, not_configured, rate limiting, and duplicate- // callback recovery — all driven purely off `ConnectorDescriptor.oauth`. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import type { AppEnv } from "@intx/hub-api"; import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; @@ -178,6 +179,12 @@ describe("sanitizeReturnPath", () => { ), ).toBe("/settings/connections"); }); + + test("malformed percent-encoding falls back to the default silently", () => { + expect(sanitizeReturnPath("%", defaultReturnPath, allowlist)).toBe( + defaultReturnPath, + ); + }); }); describe("GET /:connectorId/start", () => { @@ -677,6 +684,7 @@ describe("GET /:connectorId/callback", () => { }); test("a thrown store failure surfaces as setup_failed without leaking the key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const lines: string[] = []; const app = connectRoutes({ log: (line) => lines.push(line), @@ -697,6 +705,16 @@ describe("GET /:connectorId/callback", () => { ); expect(redirect.searchParams.get("code")).toBe("setup_failed"); expect(lines.join("\n")).not.toContain("key-for-abc123"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "oauth_connect_setup", + extra: { connectorId: "widget" }, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("key-for-abc123"); + report.mockRestore(); }); describe("open-redirect regression: the return cookie is never trusted either", () => { diff --git a/packages/connections/src/oauth-routes.ts b/packages/connections/src/oauth-routes.ts index 2d6c1fc97..f481311d5 100644 --- a/packages/connections/src/oauth-routes.ts +++ b/packages/connections/src/oauth-routes.ts @@ -42,6 +42,7 @@ import { type ConnectStateStore, } from "./pkce"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; +import { reportError } from "@corbits/error-sink"; import type { ConnectorDescriptor } from "./descriptor"; import { CONNECTOR_REGISTRY } from "./registry"; @@ -102,6 +103,11 @@ export function sanitizeReturnPath( const decodedOnceMore = decodeURIComponent(candidate); if (decodedOnceMore !== candidate) candidate = decodedOnceMore; } catch { + // report-error-ignore: CL-7247 — a malformed percent-encoding here is + // untrusted, possibly adversarial redirect input; the function's own + // contract (see header) is to fail silently to the default path + // exactly like an absent `?return=` would, never to surface as an + // error for a value nothing should have sent in the first place. return defaultReturnPath; } @@ -625,6 +631,15 @@ export function createOAuthConnectRoutes( deps.log( `${connectorId} connect setup failed for user ${user.id}: ${message}`, ); + // Never widen `extra` beyond identifiers safe to print — `cause` here + // can carry the exchanged material (apiKey/refreshToken) or the + // connector's clientSecret in scope above; reportError's redaction + // covers the error object itself, not whatever this call adds to + // context. + reportError(cause, { + operation: "oauth_connect_setup", + extra: { connectorId, userId: user.id }, + }); return c.redirect( redirectPath(returnPath, connectorId, { outcome: "error", diff --git a/packages/connections/src/oauth-tenant-connect.test.ts b/packages/connections/src/oauth-tenant-connect.test.ts index 2d4539b3f..5fc5dc30a 100644 --- a/packages/connections/src/oauth-tenant-connect.test.ts +++ b/packages/connections/src/oauth-tenant-connect.test.ts @@ -3,7 +3,8 @@ // authorize -> callback -> credential-stored round trip against a fake // provider (mirroring `oauth-routes.test.ts`'s `fakeDescriptor`), and a // mismatched-state callback that must never reach persistence at all. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { TenantEnv } from "@intx/hub-api"; @@ -163,6 +164,40 @@ describe("createTenantConnectCredential, mounted through createOAuthConnectRoute ]); }); + test("a persist failure is reported and comes back as invalid-credential", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const connectCredential = createTenantConnectCredential({ + hubUrl: "https://bench.example.com", + log: () => undefined, + registry: { widget: WIDGET_CONNECTOR }, + ensureProviderFn: async () => { + throw new Error("provider store unavailable"); + }, + }); + + const result = await connectCredential({ + c: { + get: (key: string) => + key === "tenant" ? TENANT : key === "principal" ? PRINCIPAL : null, + } as never, + connectorId: "widget", + userId: "user_1", + userEmail: "user_1@example.com", + cookies: [], + apiKey: "key-for-abc123", + }); + + expect(result.kind).toBe("invalid-credential"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_tenant_oauth_connection", + tenantId: TENANT.id, + extra: { connectorId: "widget" }, + }); + report.mockRestore(); + }); + test("a callback with no matching state never persists a credential", async () => { const { app, providers, credentials } = mountTenantScoped(); diff --git a/packages/connections/src/oauth-tenant-connect.ts b/packages/connections/src/oauth-tenant-connect.ts index 34c18e77e..550e18a4c 100644 --- a/packages/connections/src/oauth-tenant-connect.ts +++ b/packages/connections/src/oauth-tenant-connect.ts @@ -19,6 +19,7 @@ // path, which has nothing else vouching for the secret). import type { TenantEnv } from "@intx/hub-api"; import { createHubAPI } from "@workbench/hub-client"; +import { reportError } from "@corbits/error-sink"; import type { ConnectorDescriptor } from "./descriptor"; import type { ProviderHealthStore } from "./provider-health"; import { CONNECTOR_REGISTRY } from "./registry"; @@ -99,6 +100,11 @@ export function createTenantConnectCredential( deps.log( `oauth connect for ${args.connectorId} on tenant ${tenant.id} failed to persist: ${message}`, ); + reportError(cause, { + operation: "persist_tenant_oauth_connection", + tenantId: tenant.id, + extra: { connectorId: args.connectorId }, + }); return { kind: "invalid-credential", message }; } }; diff --git a/packages/connections/src/openrouter-connect.test.ts b/packages/connections/src/openrouter-connect.test.ts new file mode 100644 index 000000000..d2cd75261 --- /dev/null +++ b/packages/connections/src/openrouter-connect.test.ts @@ -0,0 +1,48 @@ +// The exchange's own contract: parse OpenRouter's response at the trust +// boundary without ever putting key material in a failure message, and +// route a transport failure through reportError. +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; +import { exchangeCodeForKey, type ExchangeFetch } from "./openrouter-connect"; + +describe("exchangeCodeForKey", () => { + test("trades the code and verifier for a durable key", async () => { + const fetchImpl: ExchangeFetch = async () => + new Response(JSON.stringify({ key: "sk-or-minted-key" }), { + status: 200, + }); + + const result = await exchangeCodeForKey({ + code: "auth_code_1", + codeVerifier: "verifier_1", + fetchImpl, + }); + + expect(result).toEqual({ ok: true, key: "sk-or-minted-key" }); + }); + + test("a transport failure is reported, never key material", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const fetchImpl: ExchangeFetch = async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }; + + const result = await exchangeCodeForKey({ + code: "auth_code_1", + codeVerifier: "verifier_1", + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain("Could not reach OpenRouter"); + expect(result.message).toContain("getaddrinfo ENOTFOUND"); + } + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_openrouter_key", + }); + report.mockRestore(); + }); +}); diff --git a/packages/connections/src/openrouter-connect.ts b/packages/connections/src/openrouter-connect.ts index cd432557d..c2d7c0bd3 100644 --- a/packages/connections/src/openrouter-connect.ts +++ b/packages/connections/src/openrouter-connect.ts @@ -7,6 +7,7 @@ // and never put in a URL. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; export const OPENROUTER_AUTH_URL = "https://openrouter.ai/auth"; export const OPENROUTER_KEY_EXCHANGE_URL = @@ -59,6 +60,7 @@ export async function exchangeCodeForKey( }), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_openrouter_key" }); return { ok: false, message: diff --git a/packages/connections/src/pkce.ts b/packages/connections/src/pkce.ts index c5840284a..9817f9252 100644 --- a/packages/connections/src/pkce.ts +++ b/packages/connections/src/pkce.ts @@ -138,6 +138,12 @@ export function createConnectStateStore(args: { if (parsed instanceof type.errors) return undefined; envelope = parsed; } catch { + // report-error-ignore: CL-7247 — decrypt/parse failure here is the + // expected outcome for a tampered, expired-then-reused, or wrong- + // provider state, indistinguishable from malicious probing by + // design (see module header). Reporting it would create a + // decrypt-failure oracle and flood the sink with routine, + // non-actionable noise. return undefined; } diff --git a/packages/connections/src/probes.test.ts b/packages/connections/src/probes.test.ts index 6939000af..63ab58dc1 100644 --- a/packages/connections/src/probes.test.ts +++ b/packages/connections/src/probes.test.ts @@ -2,7 +2,8 @@ // real network calls, no real keys — a 401 must reject, a 2xx must // accept, matching `@workbench/hub-client/credential-test`'s own // contract for `testProviderCredential`. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import type { FetchLike } from "@workbench/hub-client/credential-test"; import { testExaCredential, @@ -34,6 +35,22 @@ describe("testGranolaCredential", () => { ); expect(result.ok).toBe(true); }); + + test("a transport failure is reported by displayName, never the key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const fetchImpl: FetchLike = async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }; + const result = await testGranolaCredential("test-key", fetchImpl); + expect(result.ok).toBe(false); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "probe_credential", + extra: { displayName: "Granola" }, + }); + report.mockRestore(); + }); }); describe("testExaCredential", () => { diff --git a/packages/connections/src/probes.ts b/packages/connections/src/probes.ts index a1fb426d3..770fed12d 100644 --- a/packages/connections/src/probes.ts +++ b/packages/connections/src/probes.ts @@ -11,6 +11,7 @@ import type { CredentialTestResult, FetchLike, } from "@workbench/hub-client/credential-test"; +import { reportError } from "@corbits/error-sink"; const PROBE_TIMEOUT_MS = 5000; @@ -34,6 +35,10 @@ async function probe( if (init.body !== undefined) fetchArgs.body = init.body; response = await fetchImpl(url, fetchArgs); } catch (cause) { + reportError(cause, { + operation: "probe_credential", + extra: { displayName }, + }); return { ok: false, message: diff --git a/packages/connections/src/routes.test.ts b/packages/connections/src/routes.test.ts index e70679bd4..3e316fe74 100644 --- a/packages/connections/src/routes.test.ts +++ b/packages/connections/src/routes.test.ts @@ -5,7 +5,8 @@ // `packages/webhook-triggers/test/management-routes.test.ts`. A fake // registry stands in for `CONNECTOR_REGISTRY` so every probe outcome is // deterministic and nothing here ever touches the real network. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -622,7 +623,8 @@ describe("POST /:connectorId/complete", () => { expect(body.error.code).toBe("connection_setup_failed"); }); - test("a storage failure after a good probe 500s", async () => { + test("a storage failure after a good probe 500s and reports without leaking the key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const app = buildApp({ ensureProviderFn: async () => { throw new Error("hub unreachable"); @@ -636,6 +638,16 @@ describe("POST /:connectorId/complete", () => { expect(response.status).toBe(500); const body = (await response.json()) as { error: { code: string } }; expect(body.error.code).toBe("connection_setup_failed"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_api_key_connection", + extra: { connectorId: "accepting-connector" }, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("good-key"); + report.mockRestore(); }); test("a rejected probe reports the connector needs_attention with a category, never the probe's own message", async () => { @@ -1241,4 +1253,43 @@ describe("onInferenceCredentialUsable hook", () => { expect(response.status).toBe(200); expect(events).toHaveLength(0); }); + + test("a resolved-catalog check failure is reported and never breaks the connect", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const events: unknown[] = []; + const routes = createConnectionRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + registry: OLLAMA_REGISTRY, + ensureProviderFn: async () => "prv_1", + ensureCredentialFn: async () => "crd_1", + seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), + getResolvedCatalogFn: async () => { + throw new Error("hub unreachable"); + }, + onInferenceCredentialUsable: async (info) => { + events.push(info); + }, + }); + const app = mountAs(routes); + const response = await app.request("/ollama/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + apiKey: "https://home-mac-studio.tail87f5aa.ts.net", + }), + }); + + expect(response.status).toBe(200); + expect(events).toHaveLength(0); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "check_resolved_catalog_after_connect", + tenantId: TENANT.id, + extra: { connectorId: "ollama" }, + }); + report.mockRestore(); + }); }); diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index a181e73a3..313049115 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -532,6 +532,11 @@ export function createConnectionRoutes( deps.log( `could not check tenant ${tenant.id}'s resolved catalog after connecting ${descriptor.id}; the bench stays as-is until its next reconcile: ${message}`, ); + reportError(cause, { + operation: "check_resolved_catalog_after_connect", + tenantId: tenant.id, + extra: { connectorId: descriptor.id }, + }); } } } @@ -546,6 +551,13 @@ export function createConnectionRoutes( deps.log( `connection setup failed for connector ${connectorId} on tenant ${tenant.id}: ${message}`, ); + // Never widen extra beyond identifiers safe to print — the pasted + // `parsed.apiKey` is in scope above. + reportError(cause, { + operation: "persist_api_key_connection", + tenantId: tenant.id, + extra: { connectorId }, + }); return c.json( ErrorEnvelope( "connection_setup_failed", diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 65e2b35f1..157efd028 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -159,28 +159,6 @@ packages/connections-tools/src/tool.ts 1 return errorResult(call.id, err); packages/connections-tools/src/tool.ts 2 return errorResult(call.id, err); packages/connections-tools/src/tool.ts 3 return errorResult(call.id, err); packages/connections-tools/src/tool.ts 4 return errorResult(call.id, err); -packages/connections/src/connected-hook.ts 1 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/connected-hook.ts 2 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/github-connect.ts 1 return { -packages/connections/src/gmail-connect.ts 1 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/huggingface-connect.ts 1 return { -packages/connections/src/mcp-oauth-routes.ts 1 // malformed JSON on an error response; classifier uses the thrown error -packages/connections/src/mcp-oauth-routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/mcp-oauth-routes.ts 2 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/mcp-oauth-routes.ts 3 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/mcp-oauth.ts 1 return { -packages/connections/src/mcp-probe.ts 1 const message = -packages/connections/src/mcp-probe.ts 1 return undefined; -packages/connections/src/mcp-probe.ts 1 return { ok: false, message: `"${url}" is not a valid URL.` }; -packages/connections/src/mcp-server-routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/oauth-routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/oauth-routes.ts 1 return defaultReturnPath; -packages/connections/src/oauth-tenant-connect.ts 1 const message = cause instanceof Error ? cause.message : String(cause); -packages/connections/src/openrouter-connect.ts 1 return { -packages/connections/src/pkce.ts 1 return undefined; -packages/connections/src/probes.ts 1 return { -packages/connections/src/routes.ts 1 const message = -packages/connections/src/routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); packages/error-sink/src/index.ts 1 return context.refId ?? UNKNOWN_OPERATION; packages/folded-run-one-shot/src/one-shot-reply.ts 1 log.error`one-shot run ${triggerAddress}: undeploy failed during teardown (${reason}): ${ packages/folded-run-one-shot/src/one-shot-reply.ts 1 void settle("planning-run-send-failed", () => {