diff --git a/AgentCompany-New/src/main/database/migrations/008-provider-health.ts b/AgentCompany-New/src/main/database/migrations/008-provider-health.ts new file mode 100644 index 00000000..0adcaf5f --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/008-provider-health.ts @@ -0,0 +1,6 @@ +export const providerHealthSchema = ` + alter table providers add column auth_state text not null default 'unconfigured'; + alter table providers add column health_state text not null default 'unchecked'; + alter table providers add column last_checked_at text; + alter table providers add column last_error text; +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 2b886509..7f8fc62e 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -6,6 +6,7 @@ import { filesPinnedAndScheduledPosts } from "./migrations/004-files-posts"; import { chatReadState } from "./migrations/005-chat-read-state"; import { appStateSchema } from "./migrations/006-app-state"; import { skillHardeningSchema } from "./migrations/007-skill-hardening"; +import { providerHealthSchema } from "./migrations/008-provider-health"; const MIGRATIONS: Array<{ version: number; sql: string }> = [ { version: 1, sql: initialEnterpriseSchema }, @@ -15,6 +16,7 @@ const MIGRATIONS: Array<{ version: number; sql: string }> = [ { version: 5, sql: chatReadState }, { version: 6, sql: appStateSchema }, { version: 7, sql: skillHardeningSchema }, + { version: 8, sql: providerHealthSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/provider-adapters.ts b/AgentCompany-New/src/main/services/provider-adapters.ts index b09be353..c17e0155 100644 --- a/AgentCompany-New/src/main/services/provider-adapters.ts +++ b/AgentCompany-New/src/main/services/provider-adapters.ts @@ -15,24 +15,47 @@ export interface ProviderAdapter { } type JsonRecord = Record; +const PROVIDER_REQUEST_TIMEOUT_MS = 60_000; async function postJson(endpoint: string, headers: Record, body: JsonRecord): Promise { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - ...headers, - }, - body: JSON.stringify(body), - }); - const data = await response.json() as JsonRecord; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROVIDER_REQUEST_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + const raw = await response.text(); + const data = parseProviderJson(raw, response.ok); if (!response.ok) { - const message = readProviderError(data) ?? `Provider request failed with HTTP ${response.status}.`; + const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`); throw new Error(message); } return data; } +function parseProviderJson(raw: string, requireJson: boolean): JsonRecord { + if (!raw.trim()) return {}; + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch (error) { + if (!requireJson) return {}; + throw error; + } + if (!isRecord(value)) throw new Error("Provider returned invalid JSON."); + return value; +} + function readProviderError(data: JsonRecord): string | null { const error = data.error; if (typeof error === "string") return error; diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index 31cc185e..3d16f18a 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -9,6 +9,11 @@ import { AuditService } from "./audit-service"; import { SecretVault } from "./secret-vault"; import { providerAdapters } from "./provider-adapters"; +const AUTH_STATE_CONFIGURED = "configured"; +const HEALTH_STATE_HEALTHY = "healthy"; +const HEALTH_STATE_FAILED = "failed"; +const HEALTH_STATE_UNCHECKED = "unchecked"; + function displayHint(secret: string): string { const clean = secret.trim(); if (clean.length <= 10) return `${clean.slice(0, 2)}...`; @@ -43,8 +48,11 @@ export class ProviderService { displayHint(secret), now, ); - this.db.prepare("update providers set credential_id = ?, model = ?, enabled = 1 where id = ? and org_id = ?") - .run(credentialId, input.model, input.providerId, input.orgId); + this.db.prepare(` + update providers + set credential_id = ?, model = ?, enabled = 1, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null + where id = ? and org_id = ? + `).run(credentialId, input.model, AUTH_STATE_CONFIGURED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId); this.audit.record({ orgId: input.orgId, entityType: "provider", @@ -85,6 +93,7 @@ export class ProviderService { }, }); win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + this.attachNavigationPolicy(win, catalogEntry.signInUrl); win.loadURL(catalogEntry.signInUrl); this.audit.record({ @@ -103,11 +112,12 @@ export class ProviderService { if (!prompt) throw new Error("Prompt is required."); const provider = this.db.prepare(` select id, provider_key, auth_type, model, credential_id, enabled + , auth_state from providers where id = ? and org_id = ? `).get(input.providerId, input.orgId) as ProviderRow | undefined; if (!provider) throw new Error("Provider was not found."); - if (provider.enabled !== 1) throw new Error("Provider credential is required."); + if (provider.enabled !== 1 || provider.auth_state !== AUTH_STATE_CONFIGURED) throw new Error("Provider credential is required."); if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) { throw new Error("Provider response execution requires an API key adapter."); } @@ -155,6 +165,7 @@ export class ProviderService { set state = ?, response_text = ?, raw_response_json = ?, completed_at = ? where id = ? and org_id = ? `).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, new Date().toISOString(), requestId, input.orgId); + this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_HEALTHY, null); this.audit.record({ orgId: input.orgId, entityType: "provider_request", @@ -171,9 +182,31 @@ export class ProviderService { set state = ?, error_message = ?, completed_at = ? where id = ? and org_id = ? `).run(PROVIDER_REQUEST_STATE.FAILED, message, new Date().toISOString(), requestId, input.orgId); + this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_FAILED, message); throw error; } } + + private attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void { + const allowedHost = new URL(signInUrl).hostname.toLowerCase(); + const allow = (url: string) => { + const host = new URL(url).hostname.toLowerCase(); + return host === allowedHost || host.endsWith(`.${allowedHost}`); + }; + const blockOffHost = (event: Electron.Event, url: string) => { + if (!allow(url)) event.preventDefault(); + }; + win.webContents.on("will-navigate", blockOffHost); + win.webContents.on("will-redirect", blockOffHost); + } + + private recordProviderHealth(providerId: string, orgId: string, healthState: string, error: string | null): void { + this.db.prepare(` + update providers + set health_state = ?, last_error = ?, last_checked_at = ? + where id = ? and org_id = ? + `).run(healthState, error, new Date().toISOString(), providerId, orgId); + } } interface ProviderRow { @@ -183,6 +216,7 @@ interface ProviderRow { model: string | null; credential_id: string | null; enabled: number; + auth_state: string; } function hashPrompt(prompt: string): string { diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index ddd0a173..96708c44 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -270,6 +270,10 @@ export interface ProviderRecord { model: string | null; credentialId: string | null; enabled: boolean; + authState: string; + healthState: string; + lastCheckedAt: string | null; + lastError: string | null; createdAt: string; }