From 06eb2ce8dc6bbaae85a738a836c909c77097c0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 15:20:36 -0400 Subject: [PATCH] Q92: an unreadable or unrenewable credential is logged on its request Co-Authored-By: Claude Opus 5.5 --- .../services/provider-background-service.ts | 5 +- src/main/services/provider-response-runner.ts | 8 +-- tests/unit/credential-failure-records.test.ts | 68 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 tests/unit/credential-failure-records.test.ts diff --git a/src/main/services/provider-background-service.ts b/src/main/services/provider-background-service.ts index 1924cc5f..a6cf9815 100644 --- a/src/main/services/provider-background-service.ts +++ b/src/main/services/provider-background-service.ts @@ -69,7 +69,8 @@ export class ProviderBackgroundService { input.onRequestCreated?.(requestId); const startedAt = Date.now(); try { - const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret); + // Read inside the request's try, so an unreadable credential is logged as this request's failure. + const authProfile = execution.adapter.parseAuthProfile(this.loadCredential(input.orgId, execution.provider).secret); const result = await execution.adapter.createBackgroundResponse({ authProfile, model: execution.provider.model, @@ -234,7 +235,6 @@ export class ProviderBackgroundService { instructions: input.instructions?.trim() || null, provider: { ...provider, model: provider.model }, adapter: this.requireAdapter(provider.provider_key, provider.auth_type), - credential: this.loadCredential(input.orgId, provider), }; } @@ -326,7 +326,6 @@ interface PreparedBackgroundExecution { instructions: string | null; provider: ProviderRow & { model: string }; adapter: ProviderAdapterRegistry[string]; - credential: { secret: string }; } interface ProviderRow { diff --git a/src/main/services/provider-response-runner.ts b/src/main/services/provider-response-runner.ts index 9cca6671..56f02a0d 100644 --- a/src/main/services/provider-response-runner.ts +++ b/src/main/services/provider-response-runner.ts @@ -62,7 +62,8 @@ export class ProviderResponseRunner { input.onRequestCreated?.(requestId); const startedAt = Date.now(); try { - const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret); + // Read inside the request's try, so an unreadable credential is logged as this request's failure. + const authProfile = execution.adapter.parseAuthProfile(loadCredential(this.deps.authBroker, execution.orgId, execution.provider).secret); const result = await execution.adapter.createResponse({ authProfile, model: execution.provider.model, @@ -240,7 +241,8 @@ export class ProviderResponseRunner { let providerResponseId: string | null = null; let finished = false; try { - const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret); + // Read inside the request's try, so an unreadable credential is logged as this request's failure. + const authProfile = execution.adapter.parseAuthProfile(loadCredential(this.deps.authBroker, execution.orgId, execution.provider).secret); const result = await execution.adapter.streamResponse({ authProfile, model: execution.provider.model, @@ -376,7 +378,6 @@ export class ProviderResponseRunner { executionMode, provider: { ...provider, model: provider.model }, adapter: requireAdapter(this.deps.adapters, provider.provider_key, provider.auth_type), - credential: loadCredential(this.deps.authBroker, input.orgId, provider), }; } @@ -429,7 +430,6 @@ interface PreparedProviderExecution { executionMode: ProviderExecutionMode; provider: ProviderRow & { model: string }; adapter: ProviderAdapterRegistry[string]; - credential: { secret: string }; } interface TurnCaller { diff --git a/tests/unit/credential-failure-records.test.ts b/tests/unit/credential-failure-records.test.ts new file mode 100644 index 00000000..bc2249e0 --- /dev/null +++ b/tests/unit/credential-failure-records.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { ProviderBackgroundService } from "../../src/main/services/provider-background-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE } from "../../src/shared/domain"; +import { markModelChecked } from "../helpers/model-check"; + +afterEach(() => vi.unstubAllGlobals()); + +/** A vault that stored secrets fine but can no longer open them, as after a lost OS key. */ +function breakableVault() { + const state = { broken: false }; + return { + state, + vault: { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => { + if (state.broken) throw new Error("The stored secret could not be decrypted."); + return ciphertext.toString("utf8"); + }, + }, + }; +} + +function company(providerKey: string, secret: string, model: string) { + const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-rec-${randomUUID()}.db`)); + const { state, vault } = breakableVault(); + const providers = new ProviderService(connection.db, providerAdapters, vault); + const background = new ProviderBackgroundService(connection.db, providerAdapters, vault); + let orgId = ""; + let providerId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = ?").get(orgId, providerKey) as { id: string }).id; + providers.saveCredential({ orgId, providerId, secret, model }); + providers.updatePolicy({ orgId, providerId, dataPolicy: PROVIDER_DATA_POLICY.STANDARD, backgroundEnabled: true, monthlyRequestLimit: 100, workspaceScope: "organization", contextWindowTokens: 128_000 }); + }); + const failed = () => connection.db.prepare("select state, error_message from provider_requests order by rowid").all(); + return { connection, providers, background, orgId, providerId, state, failed }; +} + +describe("a credential that cannot be read or renewed leaves a failed request in the log", () => { + test("one-shot, streamed and background requests record the unreadable credential", async () => { + const h = company("openai", "sk-test", "gpt-5.4"); + h.state.broken = true; + const request = { orgId: h.orgId, providerId: h.providerId, prompt: "p", instructions: null }; + await expect(h.providers.createResponse({ ...request, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND })).rejects.toThrow("could not be decrypted"); + await expect(h.providers.streamResponse({ ...request, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND }, () => {})).rejects.toThrow("could not be decrypted"); + await expect(h.background.create({ ...request, executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND })).rejects.toThrow("could not be decrypted"); + expect(h.failed()).toEqual(Array.from({ length: 3 }, () => ({ state: "failed", error_message: "The stored secret could not be decrypted." }))); + h.connection.close(); + }); + + test("an agent turn whose sign-in cannot be renewed records the refresh failure", async () => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400, headers: { "content-type": "application/json" } })); + const h = company("claude-pro", JSON.stringify({ access: "a", refresh: "r", expires: 0 }), "claude-sonnet-4-5"); + markModelChecked(h.connection.db, h.providerId, "claude-sonnet-4-5"); + await expect(h.providers.runToolTurn({ orgId: h.orgId, providerId: h.providerId, workspaceId: null, instructions: "i", messages: [{ role: "user", text: "u" }], tools: [] })) + .rejects.toThrow("Claude token refresh failed: 400"); + expect(h.failed()).toEqual([{ state: "failed", error_message: expect.stringContaining("Claude token refresh failed: 400") }]); + h.connection.close(); + }); +});