diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 0b611e893..0d778e0a8 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -287,9 +287,14 @@ async function recentlyConnectedCredential( ); return match ? tenant : undefined; } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); + // Never the raw cause detail — this path lists credentials after a + // connect and can see secret-shaped hub/parse errors (CL-7255). + const refId = reportError(cause, { + operation: "onboarding_duplicate_callback_recovery", + extra: { userId: args.userId }, + }); args.log( - `duplicate-callback recovery check failed for user ${args.userId}: ${message}`, + `duplicate-callback recovery check failed for user ${args.userId} [${refId}]`, ); return undefined; } diff --git a/packages/onboarding/test/report-error-routing.test.ts b/packages/onboarding/test/report-error-routing.test.ts index 01f6e5e53..f4136ec9a 100644 --- a/packages/onboarding/test/report-error-routing.test.ts +++ b/packages/onboarding/test/report-error-routing.test.ts @@ -231,3 +231,81 @@ describe("plant-env-credentials.ts catches report through reportError and never expect(cause).toBeInstanceOf(Error); }); }); + +describe("recentlyConnectedCredential reports through reportError and still finds nothing", () => { + test("a hub failure during duplicate-callback recovery reports operation + userId and still ends as state_expired", async () => { + // A principals page arktype will reject, with the secret in a union + // field so the parse summary echoes it. `not.toContain` is otherwise a + // tautology — a type-mismatch summary is `was string` and never + // mentions the value (CL-7255). + const SECRET = "sk-or-v1-thisisafakesecretvalue"; + const logs: string[] = []; + const hub = new Hono(); + hub.get("/api/me/principals", (c) => + c.json({ + data: [ + { + principalId: "prn_1", + tenantId: "ten_1", + tenantName: "user_1's workbench", + tenantSlug: "user-1-user1", + kind: SECRET, + status: "active", + roles: [], + }, + ], + nextCursor: null, + }), + ); + const server = Bun.serve({ port: 0, fetch: hub.fetch }); + try { + const routes = createOnboardingRoutes({ + hubUrl: `http://localhost:${server.port}`, + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), + log: (line) => { + logs.push(line); + }, + pendingSeedStore, + }); + const app = mountAuthenticated(routes); + + const response = await app.request( + "/oauth/openrouter/callback?code=auth_code_1", + { + headers: { + cookie: "workbench_openrouter_connect=not-a-real-state", + }, + }, + ); + + expect(response.status).toBe(302); + const redirect = new URL( + response.headers.get("location") ?? "", + "https://x", + ); + expect(redirect.searchParams.get("outcome")).toBe("error"); + expect(redirect.searchParams.get("code")).toBe("state_expired"); + + expect(reportErrorCalls).toHaveLength(1); + const [cause, context] = reportErrorCalls[0] as [ + unknown, + Record, + ]; + expect(context.operation).toBe("onboarding_duplicate_callback_recovery"); + expect(context.tenantId).toBeUndefined(); + expect((context.extra as { userId: string }).userId).toBe("user_1"); + // reportError is the one place the unredacted cause may travel — + // error-sink redacts before anything reaches a log sink. + expect(cause).toBeInstanceOf(Error); + const causeMessage = + cause instanceof Error ? cause.message : String(cause); + expect(causeMessage).toContain(SECRET); + expect(JSON.stringify({ logs, context })).not.toContain(SECRET); + } finally { + server.stop(true); + } + }); +}); diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index dcefb0449..a52dd44b5 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -211,7 +211,6 @@ packages/ollama-adapter/src/inline-tool-json.ts 1 return false; packages/ollama-adapter/src/inline-tool-json.ts 1 return { kind: "incomplete" }; packages/onboarding/src/pending-seed.ts 1 return drop(); packages/onboarding/src/routes.ts 1 // Neither `ProvisionError` nor `CliError` messages are safe to show -packages/onboarding/src/routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); packages/onboarding/src/routes.ts 1 if (cause instanceof ProvisionError) { packages/onboarding/src/routes.ts 1 return c.json( packages/presence/src/artifact-persistence.ts 1 deps.onSnapshotError?.(key, error);