diff --git a/apps/api/src/routes/credentials.ts b/apps/api/src/routes/credentials.ts index e90175952..008856da5 100644 --- a/apps/api/src/routes/credentials.ts +++ b/apps/api/src/routes/credentials.ts @@ -1,5 +1,4 @@ -import { createProvider } from '@simple-agent-manager/providers'; -import type { AgentCredentialInfo, AgentType, CreateCredentialRequest, CredentialKind, CredentialProvider, CredentialResponse, CredentialSource } from '@simple-agent-manager/shared'; +import type { AgentCredentialInfo, AgentType, CreateCredentialRequest, CredentialKind, CredentialProvider, CredentialResponse, CredentialSource, CredentialValidationStatus } from '@simple-agent-manager/shared'; import { CREDENTIAL_PROVIDERS, getAgentDefinition, isValidAgentType } from '@simple-agent-manager/shared'; import { and, eq, isNull } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/d1'; @@ -11,15 +10,15 @@ import { maskCredential } from '../lib/credential-mask'; import { log } from '../lib/logger'; import { getCredentialEncryptionKey } from '../lib/secrets'; import { ulid } from '../lib/ulid'; -import { getUserId,requireApproved, requireAuth } from '../middleware/auth'; +import { getUserId, requireApproved, requireAuth } from '../middleware/auth'; import { errors } from '../middleware/error'; import { rateLimitCredentialUpdate } from '../middleware/rate-limit'; -import { CreateCredentialSchema, CredentialKindBodySchema,jsonValidator, SaveAgentCredentialSchema } from '../schemas'; -import { validateAgentApiKeyWithProvider } from '../services/agent-credential-validation'; +import { CreateCredentialSchema, CredentialKindBodySchema, jsonValidator, SaveAgentCredentialSchema } from '../schemas'; import { decrypt, encrypt } from '../services/encryption'; +import { getTimeoutMs } from '../services/fetch-timeout'; import { getPlatformAgentCredential } from '../services/platform-credentials'; -import { buildProviderConfig, serializeCredentialToken } from '../services/provider-credentials'; -import { CredentialValidator } from '../services/validation'; +import { serializeCredentialToken } from '../services/provider-credentials'; +import { CredentialValidator, formatOnlyValidation, validateAgentApiKeyCredentialWithProvider, validateHetznerCredentialWithProvider, validateScalewayCredentialWithProvider } from '../services/validation'; const credentialsRoutes = new Hono<{ Bindings: Env }>(); @@ -79,19 +78,45 @@ function getCloudCredentialFields(body: CreateCredentialRequest): CloudCredentia throw errors.badRequest(`Unsupported provider: ${providerName}`); } -async function validateCloudCredential(providerName: CredentialProvider, tokenToValidate: string): Promise { - if (providerName === 'gcp') return; +const DEFAULT_SAVE_VALIDATION_TIMEOUT_MS = 8000; - try { - const providerConfig = buildProviderConfig(providerName, tokenToValidate); - const provider = createProvider(providerConfig); - await provider.validateToken(); - } catch (err) { - log.error('credentials.validation_failed', { providerName, error: err instanceof Error ? err.message : String(err) }); - throw errors.badRequest(`Invalid or unauthorized ${providerName} credentials`); +function getSaveValidationTimeoutMs(env: Env): number { + return getTimeoutMs(env.AGENT_CREDENTIAL_VALIDATION_TIMEOUT_MS, DEFAULT_SAVE_VALIDATION_TIMEOUT_MS); +} + +async function validateCloudCredentialRequest( + body: CreateCredentialRequest, + env: Env, +): Promise { + if (body.provider === 'hetzner') { + return validateHetznerCredentialWithProvider(body.token, { timeoutMs: getSaveValidationTimeoutMs(env) }); + } + + if (body.provider === 'scaleway') { + return validateScalewayCredentialWithProvider(body.secretKey, body.projectId, { + timeoutMs: getSaveValidationTimeoutMs(env), + }); + } + + return formatOnlyValidation('GCP credential metadata accepted. Live validation runs during Google setup.'); +} + +function rejectInvalidCredentialValidation(validation: CredentialValidationStatus): void { + if (!validation.valid) { + throw errors.badRequest(validation.error ?? validation.message); } } +function logCredentialValidationWarning(scope: 'cloud' | 'agent', providerName: string, validation: CredentialValidationStatus): void { + if (validation.valid) return; + log.warn('credentials.validation_warning', { + scope, + providerName, + status: validation.status, + error: validation.error ?? validation.message, + }); +} + // Apply auth middleware to all routes credentialsRoutes.use('*', requireAuth(), requireApproved()); @@ -131,15 +156,13 @@ credentialsRoutes.get('/', async (c) => { */ credentialsRoutes.post('/validate', jsonValidator(CreateCredentialSchema), async (c) => { const body = c.req.valid('json'); - const { providerName, tokenToValidate } = getCloudCredentialFields(body); - await validateCloudCredential(providerName, tokenToValidate); + const { providerName } = getCloudCredentialFields(body); + const validation = await validateCloudCredentialRequest(body, c.env); + rejectInvalidCredentialValidation(validation); return c.json({ - valid: true, provider: providerName, - message: providerName === 'gcp' - ? 'GCP credential metadata accepted. Live validation runs during Google setup.' - : `${providerName} credential validated.`, + ...validation, }); }); @@ -150,8 +173,10 @@ credentialsRoutes.post('/', jsonValidator(CreateCredentialSchema), async (c) => const userId = getUserId(c); const db = drizzle(c.env.DATABASE, { schema }); - const { providerName, tokenToValidate: tokenToEncrypt } = getCloudCredentialFields(c.req.valid('json')); - await validateCloudCredential(providerName, tokenToEncrypt); + const requestBody = c.req.valid('json'); + const { providerName, tokenToValidate: tokenToEncrypt } = getCloudCredentialFields(requestBody); + const validation = await validateCloudCredentialRequest(requestBody, c.env); + logCredentialValidationWarning('cloud', providerName, validation); // Encrypt the serialized credential token const { ciphertext, iv } = await encrypt(tokenToEncrypt, getCredentialEncryptionKey(c.env)); @@ -187,6 +212,7 @@ credentialsRoutes.post('/', jsonValidator(CreateCredentialSchema), async (c) => provider: providerName, connected: true, createdAt: existingCred.createdAt, + validation, }; return c.json(response); @@ -210,6 +236,7 @@ credentialsRoutes.post('/', jsonValidator(CreateCredentialSchema), async (c) => provider: providerName, connected: true, createdAt: now, + validation, }; return c.json(response, 201); @@ -278,7 +305,10 @@ credentialsRoutes.post('/agent/validate', jsonValidator(SaveAgentCredentialSchem }); } - const result = await validateAgentApiKeyWithProvider(body.agentType, body.credential, c.env); + const result = await validateAgentApiKeyCredentialWithProvider(body.agentType, body.credential, { + timeoutMs: getSaveValidationTimeoutMs(c.env), + }); + rejectInvalidCredentialValidation(result); return c.json({ agentType: body.agentType, ...result }); }); @@ -378,6 +408,13 @@ credentialsRoutes.put('/agent', (c, next) => rateLimitCredentialUpdate(c.env)(c, throw errors.badRequest(`OAuth tokens are not supported for ${agentDef.name}`); } + const providerValidation = credentialKind === 'api-key' + ? await validateAgentApiKeyCredentialWithProvider(body.agentType, credential, { + timeoutMs: getSaveValidationTimeoutMs(c.env), + }) + : formatOnlyValidation(`${agentDef.name} OAuth credential format looks valid.`); + logCredentialValidationWarning('agent', agentDef.provider, providerValidation); + // Encrypt the credential const { ciphertext, iv } = await encrypt(credential, getCredentialEncryptionKey(c.env)); @@ -450,6 +487,7 @@ credentialsRoutes.put('/agent', (c, next) => rateLimitCredentialUpdate(c.env)(c, credentialKind, isActive: autoActivate, maskedKey, + validation: providerValidation, label: credentialKind === 'oauth-token' ? 'Pro/Max Subscription' : undefined, createdAt: existingCred.createdAt, updatedAt: now, @@ -465,6 +503,7 @@ credentialsRoutes.put('/agent', (c, next) => rateLimitCredentialUpdate(c.env)(c, credentialKind, isActive: autoActivate, maskedKey, + validation: providerValidation, label: credentialKind === 'oauth-token' ? 'Pro/Max Subscription' : undefined, createdAt: now, updatedAt: now, diff --git a/apps/api/src/services/validation.ts b/apps/api/src/services/validation.ts index 319cdf15a..a62152686 100644 --- a/apps/api/src/services/validation.ts +++ b/apps/api/src/services/validation.ts @@ -1,6 +1,8 @@ -import type { AgentType, CredentialKind } from '@simple-agent-manager/shared'; +import type { AgentType, CredentialKind, CredentialValidationStatus } from '@simple-agent-manager/shared'; +import { DEFAULT_SCALEWAY_ZONE, getAgentDefinition } from '@simple-agent-manager/shared'; import { expectJsonRecord, maybeJsonRecord } from '../lib/runtime-validation'; +import { fetchWithTimeout } from './fetch-timeout'; const ANTHROPIC_API_KEY_PREFIX = 'sk-ant-api'; const CLAUDE_OAUTH_TOKEN_PREFIX = 'sk-ant-oat'; @@ -28,7 +30,8 @@ function decodeJwtPayload(jwt: string): Record | null { const parts = jwt.split('.'); if (parts.length !== 3) return null; // Base64url → Base64 → decode - const payload = parts[1]!; + const payload = parts[1]; + if (!payload) return null; const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); const json = atob(base64); return JSON.parse(json); @@ -116,6 +119,145 @@ export function validateOpenAICodexAuthJson(credential: string): OpenAIAuthJsonV }; } + +const DEFAULT_CREDENTIAL_VALIDATION_TIMEOUT_MS = 8000; + +interface ProviderCheck { + displayName: string; + request: string | URL; + init: RequestInit; +} + +export interface CredentialValidationOptions { + timeoutMs?: number; +} + +function statusMessage(status: number, statusText: string): string { + return `${status} ${statusText || 'Provider Error'}`; +} + +function providerRejected(displayName: string, response: Response): CredentialValidationStatus { + const message = `Token rejected by ${displayName} API (${statusMessage(response.status, response.statusText)})`; + return { + valid: false, + message, + error: message, + status: response.status, + validationMode: 'provider', + }; +} + +function providerUnavailable(displayName: string, err: unknown): CredentialValidationStatus { + const detail = err instanceof Error ? err.message : String(err); + const message = `Could not validate with ${displayName} API: ${detail}`; + return { + valid: false, + message, + error: message, + validationMode: 'provider', + }; +} + +async function runProviderCheck( + check: ProviderCheck, + successMessage: string, + options: CredentialValidationOptions = {}, +): Promise { + try { + const response = await fetchWithTimeout( + check.request, + check.init, + options.timeoutMs ?? DEFAULT_CREDENTIAL_VALIDATION_TIMEOUT_MS, + ); + + if (response.ok) { + return { valid: true, message: successMessage, validationMode: 'provider' }; + } + + return providerRejected(check.displayName, response); + } catch (err) { + return providerUnavailable(check.displayName, err); + } +} + +export function formatOnlyValidation(message: string): CredentialValidationStatus { + return { valid: true, message, validationMode: 'format' }; +} + +export async function validateHetznerCredentialWithProvider( + token: string, + options?: CredentialValidationOptions, +): Promise { + return runProviderCheck( + { + displayName: 'Hetzner', + request: 'https://api.hetzner.cloud/v1/servers', + init: { headers: { Authorization: `Bearer ${token}` } }, + }, + 'Hetzner credential validated.', + options, + ); +} + +export async function validateScalewayCredentialWithProvider( + secretKey: string, + projectId: string, + options?: CredentialValidationOptions, +): Promise { + const query = new URLSearchParams({ per_page: '1', project: projectId }); + return runProviderCheck( + { + displayName: 'Scaleway', + request: `https://api.scaleway.com/instance/v1/zones/${DEFAULT_SCALEWAY_ZONE}/servers?${query.toString()}`, + init: { headers: { 'X-Auth-Token': secretKey } }, + }, + 'Scaleway credential validated.', + options, + ); +} + +export async function validateAgentApiKeyCredentialWithProvider( + agentType: AgentType, + credential: string, + options?: CredentialValidationOptions, +): Promise { + const agentDef = getAgentDefinition(agentType); + if (!agentDef) { + return { valid: false, message: 'Unknown agent type', error: 'Unknown agent type', validationMode: 'format' }; + } + + if (agentDef.provider === 'anthropic') { + return runProviderCheck( + { + displayName: 'Anthropic', + request: 'https://api.anthropic.com/v1/models', + init: { + headers: { + 'x-api-key': credential, + 'anthropic-version': '2023-06-01', + }, + }, + }, + `${agentDef.name} credential validated.`, + options, + ); + } + + if (agentDef.provider === 'openai') { + return runProviderCheck( + { + displayName: 'OpenAI', + request: 'https://api.openai.com/v1/models', + init: { headers: { Authorization: `Bearer ${credential}` } }, + }, + `${agentDef.name} credential validated.`, + options, + ); + } + + return formatOnlyValidation('Credential format looks valid. Provider reachability validation is not available for this agent.'); +} + /** * Validate and detect credential format */ diff --git a/apps/api/tests/unit/routes/cloud-provider-credentials.test.ts b/apps/api/tests/unit/routes/cloud-provider-credentials.test.ts index 59e689467..7976421d2 100644 --- a/apps/api/tests/unit/routes/cloud-provider-credentials.test.ts +++ b/apps/api/tests/unit/routes/cloud-provider-credentials.test.ts @@ -11,7 +11,7 @@ * * Mocking strategy: * - drizzle-orm/d1 is mocked so DB calls are controlled per test - * - @simple-agent-manager/providers is mocked so validateToken() is controlled + * - global fetch is mocked so upstream validation responses are controlled * - serializeCredentialToken/buildProviderConfig are exercised through the * route handler (not mocked) so the full path is covered * - encrypt is mocked to avoid requiring a real WebCrypto environment @@ -40,17 +40,6 @@ vi.mock('../../../src/services/encryption', () => ({ decrypt: vi.fn().mockResolvedValue('decrypted-value'), })); -// Mock the providers package so validateToken() is controlled per test -const mockValidateToken = vi.fn(); -const mockProvider = { validateToken: mockValidateToken }; -vi.mock('@simple-agent-manager/providers', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - createProvider: vi.fn(() => mockProvider), - }; -}); - // ============================================================================ // Test Setup // ============================================================================ @@ -66,7 +55,9 @@ async function expectCredentialValidationFailure( body: unknown, expectedProvider: string ) { - mockValidateToken.mockRejectedValueOnce(new Error('Unauthorized')); + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'bad key' }), { status: 401, statusText: 'Unauthorized' }) + ); const res = await app.request( path, @@ -80,7 +71,7 @@ async function expectCredentialValidationFailure( expect(res.status).toBe(400); const responseBody = await res.json(); - expect(responseBody.message).toContain(`Invalid or unauthorized ${expectedProvider} credentials`); + expect(responseBody.message).toContain(`Token rejected by ${expectedProvider} API (401 Unauthorized)`); } // ============================================================================ @@ -99,7 +90,10 @@ describe('POST /api/credentials — cloud-provider credentials', () => { mockDB.limit.mockResolvedValue([]); (drizzle as any).mockReturnValue(mockDB); - mockValidateToken.mockResolvedValue(true); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ servers: [] }), { status: 200 })) + ); }); it('creates a hetzner credential and returns 201', async () => { @@ -251,23 +245,13 @@ describe('POST /api/credentials — cloud-provider credentials', () => { expect(body.message).toContain('projectId'); }); - it('returns 400 (not 500) when validateToken throws (invalid credentials)', async () => { - // validateToken() throws when credentials are rejected by the provider API. - // The route must translate this into a user-facing 400, not an unhandled 500. - await expectCredentialValidationFailure( - app, - '/api/credentials', - { provider: 'hetzner', token: 'bad-token' }, - 'hetzner' - ); - }); - - it('calls validateToken before encrypting or storing the credential', async () => { + it('saves and returns a validation warning when Hetzner rejects the token', async () => { const { encrypt } = await import('../../../src/services/encryption'); + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'bad key' }), { status: 401, statusText: 'Unauthorized' }) + ); - mockValidateToken.mockRejectedValueOnce(new Error('Invalid')); - - await app.request( + const res = await app.request( '/api/credentials', { method: 'POST', @@ -277,14 +261,18 @@ describe('POST /api/credentials — cloud-provider credentials', () => { mockEnv ); - // encrypt must not be called when validation fails — credentials should - // never be stored if they are invalid. - expect(encrypt).not.toHaveBeenCalled(); - expect(mockDB.insert).not.toHaveBeenCalled(); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.validation.valid).toBe(false); + expect(body.validation.error).toContain('Token rejected by Hetzner API (401 Unauthorized)'); + expect(encrypt).toHaveBeenCalled(); + expect(mockDB.insert).toHaveBeenCalled(); }); - it('provider name appears in the 400 error message for scaleway validation failure', async () => { - mockValidateToken.mockRejectedValueOnce(new Error('Forbidden')); + it('saves and returns provider-specific validation warning for Scaleway failure', async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'forbidden' }), { status: 403, statusText: 'Forbidden' }) + ); const res = await app.request( '/api/credentials', @@ -300,9 +288,10 @@ describe('POST /api/credentials — cloud-provider credentials', () => { mockEnv ); - expect(res.status).toBe(400); + expect(res.status).toBe(201); const body = await res.json(); - expect(body.message).toContain('scaleway'); + expect(body.validation.valid).toBe(false); + expect(body.validation.error).toContain('Token rejected by Scaleway API (403 Forbidden)'); }); }); @@ -312,7 +301,10 @@ describe('POST /api/credentials/validate — cloud-provider validation', () => { beforeEach(() => { app = createCredentialsTestApp(); vi.clearAllMocks(); - mockValidateToken.mockResolvedValue(true); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ servers: [] }), { status: 200 })) + ); }); it('validates a Hetzner token without encrypting or storing it', async () => { @@ -332,7 +324,10 @@ describe('POST /api/credentials/validate — cloud-provider validation', () => { const body = await res.json(); expect(body.valid).toBe(true); expect(body.provider).toBe('hetzner'); - expect(mockValidateToken).toHaveBeenCalledOnce(); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.hetzner.cloud/v1/servers', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer htz-api-token' }) }) + ); expect(encrypt).not.toHaveBeenCalled(); }); @@ -341,7 +336,7 @@ describe('POST /api/credentials/validate — cloud-provider validation', () => { app, '/api/credentials/validate', { provider: 'hetzner', token: 'bad-token' }, - 'hetzner' + 'Hetzner' ); }); }); diff --git a/apps/api/tests/unit/routes/credentials.test.ts b/apps/api/tests/unit/routes/credentials.test.ts index df1c47274..8353b49f1 100644 --- a/apps/api/tests/unit/routes/credentials.test.ts +++ b/apps/api/tests/unit/routes/credentials.test.ts @@ -112,7 +112,7 @@ describe('Credentials Routes - OAuth Support', () => { it('returns 400 when provider validation rejects the API key', async () => { vi.mocked(globalThis.fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: 'bad key' }), { status: 401 }) + new Response(JSON.stringify({ error: 'bad key' }), { status: 401, statusText: 'Unauthorized' }) ); const claudeApiKey = makeFakeSecret('sk-ant-api03'); @@ -132,7 +132,7 @@ describe('Credentials Routes - OAuth Support', () => { expect(res.status).toBe(400); const body = await res.json(); - expect(body.message).toContain('Invalid or unauthorized Claude Code credential'); + expect(body.message).toContain('Token rejected by Anthropic API (401 Unauthorized)'); }); it('validates OAuth credentials by format only', async () => { @@ -157,16 +157,16 @@ describe('Credentials Routes - OAuth Support', () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); - it('validates Google API keys without placing the credential in the URL', async () => { + it('validates OpenAI API keys with a Bearer token against the models endpoint', async () => { const res = await app.request( '/api/credentials/agent/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - agentType: 'google-gemini', + agentType: 'openai-codex', credentialKind: 'api-key', - credential: 'google-api-key-1234567890', + credential: 'openai-api-key-1234567890', }), }, makeTestEnv() @@ -174,14 +174,11 @@ describe('Credentials Routes - OAuth Support', () => { expect(res.status).toBe(200); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://generativelanguage.googleapis.com/v1beta/models', + 'https://api.openai.com/v1/models', expect.objectContaining({ - headers: expect.objectContaining({ 'x-goog-api-key': 'google-api-key-1234567890' }), + headers: expect.objectContaining({ Authorization: 'Bearer openai-api-key-1234567890' }), }) ); - expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).not.toContain( - 'google-api-key-1234567890' - ); }); }); @@ -300,6 +297,31 @@ describe('Credentials Routes - OAuth Support', () => { expect(res.status).toBe(201); }); + it('saves an API key and returns a warning when provider validation rejects it', async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'bad key' }), { status: 401, statusText: 'Unauthorized' }) + ); + + const res = await app.request( + '/api/credentials/agent', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentType: 'claude-code', + credentialKind: 'api-key', + credential: 'sk-ant-api03-1234567890abcdef', + }), + }, + makeTestEnv() + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.validation.valid).toBe(false); + expect(body.validation.error).toContain('Token rejected by Anthropic API (401 Unauthorized)'); + }); + it('should accept an Amp API key from the shared agent catalog', async () => { mockDB.limit.mockResolvedValueOnce([]); diff --git a/apps/api/tests/unit/services/validation.test.ts b/apps/api/tests/unit/services/validation.test.ts index c1b4cd74b..94574f164 100644 --- a/apps/api/tests/unit/services/validation.test.ts +++ b/apps/api/tests/unit/services/validation.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CredentialValidator, validateOpenAICodexAuthJson } from '../../../src/services/validation'; +import { CredentialValidator, validateAgentApiKeyCredentialWithProvider, validateHetznerCredentialWithProvider, validateOpenAICodexAuthJson, validateScalewayCredentialWithProvider } from '../../../src/services/validation'; describe('CredentialValidator', () => { describe('detectCredentialKind', () => { @@ -276,3 +276,82 @@ describe('validateOpenAICodexAuthJson', () => { expect(result.error).toContain('access_token'); }); }); + + +describe('provider credential validation helpers', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('validates Hetzner credentials against the servers endpoint', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); + + const result = await validateHetznerCredentialWithProvider('hetzner-token', { timeoutMs: 1000 }); + + expect(result.valid).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.hetzner.cloud/v1/servers', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer hetzner-token' }), + }) + ); + }); + + it('returns a clear Hetzner rejection for 401 responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('{}', { status: 401, statusText: 'Unauthorized' })) + ); + + const result = await validateHetznerCredentialWithProvider('bad-token', { timeoutMs: 1000 }); + + expect(result.valid).toBe(false); + expect(result.error).toBe('Token rejected by Hetzner API (401 Unauthorized)'); + expect(result.status).toBe(401); + }); + + it('validates Scaleway credentials against a project-scoped servers endpoint', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); + + const result = await validateScalewayCredentialWithProvider('scw-secret', 'project-id', { timeoutMs: 1000 }); + + expect(result.valid).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.scaleway.com/instance/v1/zones/fr-par-1/servers?per_page=1&project=project-id', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Auth-Token': 'scw-secret' }), + }) + ); + }); + + it('validates Anthropic agent API keys with x-api-key', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); + + const result = await validateAgentApiKeyCredentialWithProvider('claude-code', 'sk-ant-api03-valid', { timeoutMs: 1000 }); + + expect(result.valid).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'sk-ant-api03-valid', + 'anthropic-version': '2023-06-01', + }), + }) + ); + }); + + it('validates OpenAI agent API keys with bearer auth', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); + + const result = await validateAgentApiKeyCredentialWithProvider('openai-codex', 'openai-key', { timeoutMs: 1000 }); + + expect(result.valid).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer openai-key' }), + }) + ); + }); +}); diff --git a/apps/web/src/components/AgentCard.tsx b/apps/web/src/components/AgentCard.tsx index 7d833ff72..a250348b8 100644 --- a/apps/web/src/components/AgentCard.tsx +++ b/apps/web/src/components/AgentCard.tsx @@ -18,7 +18,7 @@ export interface AgentCardProps { agent: AgentInfo; credentials: AgentCredentialInfo[] | null; settings: AgentSettingsResponse | null; - onSaveCredential: (request: SaveAgentCredentialRequest) => Promise; + onSaveCredential: (request: SaveAgentCredentialRequest) => Promise; onDeleteCredential: (agentType: AgentType, credentialKind: CredentialKind) => Promise; onSaveSettings: (agentType: AgentType, data: SaveAgentSettingsRequest) => Promise; onResetSettings: (agentType: AgentType) => Promise; diff --git a/apps/web/src/components/AgentKeyCard.tsx b/apps/web/src/components/AgentKeyCard.tsx index 1a52c50f7..83f953d20 100644 --- a/apps/web/src/components/AgentKeyCard.tsx +++ b/apps/web/src/components/AgentKeyCard.tsx @@ -6,7 +6,7 @@ import { useState } from 'react'; interface AgentKeyCardProps { agent: AgentInfo; credentials?: AgentCredentialInfo[] | null; // Now an array for multiple credential types - onSave: (request: SaveAgentCredentialRequest) => Promise; + onSave: (request: SaveAgentCredentialRequest) => Promise; onDelete: (agentType: AgentType, credentialKind: CredentialKind) => Promise; /** The currently selected OpenCode provider (from agent settings). Affects key labels. */ opencodeProvider?: OpenCodeProvider | null; @@ -33,6 +33,7 @@ export function AgentKeyCard({ agent, credentials, onSave, onDelete, opencodePro const [credential, setCredential] = useState(''); const [credentialKind, setCredentialKind] = useState('api-key'); const [loading, setLoading] = useState(false); + const [validationMessage, setValidationMessage] = useState(null); const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); @@ -43,6 +44,9 @@ export function AgentKeyCard({ agent, credentials, onSave, onDelete, opencodePro // Find active credential const activeCredential = credentials?.find(c => c.isActive); const hasAnyCredential = (credentials?.length ?? 0) > 0; + let saveCredentialLabel = 'Save Credential'; + if (hasAnyCredential) saveCredentialLabel = 'Update Credential'; + if (loading) saveCredentialLabel = 'Testing...'; // OpenCode can use Scaleway cloud credential as fallback (only when using Scaleway provider). // Project scope does not display provider-derived fallbacks — provider selection is user-scoped, @@ -68,14 +72,20 @@ export function AgentKeyCard({ agent, credentials, onSave, onDelete, opencodePro e.preventDefault(); setLoading(true); setError(null); + setValidationMessage(null); try { - await onSave({ + const result = await onSave({ agentType: agent.id, credentialKind, credential, autoActivate: true, }); + if (result.validation?.valid === false) { + setError(`Saved, but ${result.validation.error ?? result.validation.message}`); + return; + } + setValidationMessage(result.validation?.message ?? 'Credential validated.'); setCredential(''); setShowForm(false); } catch (err) { @@ -178,7 +188,7 @@ export function AgentKeyCard({ agent, credentials, onSave, onDelete, opencodePro