Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"prebuild:renderer": "npm run gen:catalog",
"catalog:check": "node scripts/check-catalog-stale.mjs",
"test": "npm run build:main && npm run test:unit",
"test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs",
"test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs && node scripts/test-probes-cloud.mjs",
"test:extractor": "node scripts/test-extract-base-url.mjs",
"package": "npm run build && electron-builder"
},
Expand Down
170 changes: 170 additions & 0 deletions scripts/test-probes-cloud.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* Unit tests for src/main/probes/{aws,gcp,azure}.ts (via dist build).
* All HTTP is mocked with a local fixture server. No live provider keys.
*/
import { createRequire } from 'node:module'
import { createServer } from 'node:http'
import { existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
for (const rel of ['dist/main/probes/aws.js', 'dist/main/probes/gcp.js', 'dist/main/probes/azure.js', 'dist/main/probes/index.js']) {
if (!existsSync(join(root, rel))) {
console.error(`${rel} missing - run npm run build:main first`)
process.exit(1)
}
}

const require = createRequire(import.meta.url)
const { probeBedrock, signBedrockRequest, parseAwsError } = require(join(root, 'dist/main/probes/aws.js'))
const { probeVertex, vertexModelsUrl } = require(join(root, 'dist/main/probes/gcp.js'))
const { probeAzure } = require(join(root, 'dist/main/probes/azure.js'))
const { runProbe } = require(join(root, 'dist/main/probes/index.js'))

let failed = 0
function assert(cond, msg) {
if (cond) console.log(` ✓ ${msg}`)
else {
failed++
console.error(` ✗ ${msg}`)
}
}

// Deterministic fixture server: handler swapped per case, requests recorded.
let handler = (_req, res) => { res.writeHead(200); res.end('{}') }
const requests = []
const server = createServer((req, res) => {
requests.push({ method: req.method, url: req.url, headers: req.headers })
handler(req, res)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const endpoint = `http://127.0.0.1:${server.address().port}`
const lastRequest = () => requests[requests.length - 1]

// Keep the AWS env chain empty unless a case sets it explicitly.
const savedEnv = { ...process.env }
for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION']) {
delete process.env[key]
}

const fakeCreds = { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }

// AWS: unconfigured credential chain.
{
const r = await probeBedrock({ endpoint })
assert(r.valid === false && r.status === 'invalid', 'aws no credentials returns invalid')
assert(r.detail.includes('AWS error:'), 'aws no credentials detail carries AWS error')
}

// AWS: signed request validates against a mocked 200.
{
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"modelSummaries":[]}') }
const before = requests.length
const r = await probeBedrock({ credentials: fakeCreds, region: 'us-east-1', endpoint })
assert(r.valid === true && r.status === 'ok', 'aws mocked 200 returns ok')
assert(requests.length === before + 1, 'aws probe hit the fixture server once')
const req = lastRequest()
assert(req.url === '/foundation-models', 'aws request path is /foundation-models')
assert(req.headers['x-amz-date'], 'aws request carries x-amz-date')
const auth = req.headers.authorization ?? ''
assert(auth.startsWith('AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/'), 'aws Authorization uses SigV4 credential scope')
assert(auth.includes('/us-east-1/bedrock/aws4_request'), 'aws credential scope names region and bedrock service')
assert(/Signature=[0-9a-f]{64}/.test(auth), 'aws Authorization carries a hex signature')
}

// AWS: mocked 403 XML error surfaces the AWS error code.
{
handler = (_req, res) => {
res.writeHead(403, { 'content-type': 'application/xml' })
res.end('<Error><Code>AccessDeniedException</Code><Message>not entitled</Message></Error>')
}
const r = await probeBedrock({ credentials: fakeCreds, region: 'us-east-1', endpoint })
assert(r.valid === false && r.status === 'invalid', 'aws mocked 403 returns invalid')
assert(r.detail.includes('AWS error: AccessDeniedException'), 'aws detail surfaces the AWS error code')
assert(r.detail.includes('not entitled'), 'aws detail surfaces the AWS error message')
}

// AWS: signing is deterministic for a fixed clock and secret-sensitive.
{
const now = new Date('2026-01-02T03:04:05Z')
const a = signBedrockRequest({ endpoint: 'https://bedrock.us-east-1.amazonaws.com', region: 'us-east-1', credentials: fakeCreds, now })
const b = signBedrockRequest({ endpoint: 'https://bedrock.us-east-1.amazonaws.com', region: 'us-east-1', credentials: fakeCreds, now })
assert(a.headers.Authorization === b.headers.Authorization, 'aws SigV4 signature is deterministic for a fixed clock')
const c = signBedrockRequest({ endpoint: 'https://bedrock.us-east-1.amazonaws.com', region: 'us-east-1', credentials: { ...fakeCreds, secretAccessKey: 'other-secret' }, now })
assert(c.headers.Authorization !== a.headers.Authorization, 'aws SigV4 signature changes with the secret')
const parsed = parseAwsError('<Error><Code>ValidationException</Code><Message>bad</Message></Error>')
assert(parsed.code === 'ValidationException' && parsed.message === 'bad', 'parseAwsError reads XML code and message')
}

// GCP: project and region are required before any HTTP call.
{
const before = requests.length
const r = await probeVertex({ apiKey: 'ya29.fake-token' })
assert(r.valid === false && r.status === 'error', 'gcp missing project and region returns error')
assert(r.detail.includes('project and region'), 'gcp missing config detail names project and region')
assert(requests.length === before, 'gcp missing config makes no HTTP request')
const noToken = await probeVertex({ apiKey: '', project: 'my-project', region: 'us-central1', endpoint })
assert(noToken.valid === false && noToken.status === 'invalid', 'gcp missing token returns invalid')
}

// GCP: mocked 200 validates, mocked 403 surfaces the Google error.
{
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"models":[]}') }
const r = await probeVertex({ apiKey: 'ya29.fake-token', project: 'my-project', region: 'us-central1', endpoint })
assert(r.valid === true && r.status === 'ok', 'gcp mocked 200 returns ok')
const req = lastRequest()
assert(req.url === '/v1/projects/my-project/locations/us-central1/publishers/google/models', 'gcp request path carries project and region')
assert(req.headers.authorization === 'Bearer ya29.fake-token', 'gcp request sends the Bearer token')

handler = (_req, res) => {
res.writeHead(403, { 'content-type': 'application/json' })
res.end('{"error":{"status":"PERMISSION_DENIED","message":"Permission denied"}}')
}
const denied = await probeVertex({ apiKey: 'ya29.fake-token', project: 'my-project', region: 'us-central1', endpoint })
assert(denied.valid === false && denied.status === 'invalid', 'gcp mocked 403 returns invalid')
assert(denied.detail.includes('PERMISSION_DENIED'), 'gcp detail surfaces the Google error status')
assert(
vertexModelsUrl('p', 'global') === 'https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/google/models',
'gcp global region uses the global aiplatform host',
)
}

// Azure: endpoint required, mocked 200 validates with the api-key header.
{
const noEndpoint = await probeAzure({ apiKey: 'fake-key' })
assert(noEndpoint.valid === false && noEndpoint.status === 'error', 'azure missing endpoint returns error')
assert(noEndpoint.detail.includes('endpoint'), 'azure missing endpoint detail names the endpoint')

handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"data":[]}') }
const r = await probeAzure({ apiKey: 'fake-key', baseUrl: endpoint })
assert(r.valid === true && r.status === 'ok', 'azure mocked 200 returns ok')
const req = lastRequest()
assert(req.url === '/openai/deployments?api-version=2024-02-01', 'azure request path lists deployments')
assert(req.headers['api-key'] === 'fake-key', 'azure request sends the api-key header')

handler = (_req, res) => { res.writeHead(401); res.end('{}') }
const denied = await probeAzure({ apiKey: 'fake-key', baseUrl: endpoint })
assert(denied.valid === false && denied.status === 'invalid', 'azure mocked 401 returns invalid')
}

// runProbe dispatch: cloud kinds no longer fall through to No probe implemented.
{
const bedrock = await runProbe({ providerId: 'bedrock', apiKey: '' })
assert(bedrock.detail.includes('AWS error:'), 'runProbe dispatches bedrock to the aws probe')
const vertex = await runProbe({ providerId: 'vertex', apiKey: 'ya29.fake-token' })
assert(vertex.detail.includes('project and region'), 'runProbe dispatches vertex to the gcp probe')
const azure = await runProbe({ providerId: 'azure-openai', apiKey: 'fake-key' })
assert(azure.detail.includes('endpoint'), 'runProbe dispatches azure-openai to the azure probe')
const unknown = await runProbe({ providerId: 'no-such-provider', apiKey: 'x' })
assert(unknown.detail.includes('No probe implemented'), 'runProbe still reports unimplemented providers')
}

Object.assign(process.env, savedEnv)
server.close()
if (failed > 0) {
console.error(`\n${failed} cloud probe case(s) failed`)
process.exit(1)
}
console.log('\nall cloud probe cases passed')
147 changes: 147 additions & 0 deletions src/main/probes/aws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { createHash, createHmac } from 'node:crypto'
import type { ProbeResult } from './types'

const TIMEOUT_MS = 5000
const SERVICE = 'bedrock'
const DEFAULT_REGION = 'us-east-1'

export interface AwsCredentials {
accessKeyId: string
secretAccessKey: string
sessionToken?: string
}

export interface BedrockProbeOptions {
/** Explicit credentials. Falls back to the AWS env chain when omitted. */
credentials?: AwsCredentials
/** Region override. Falls back to AWS_REGION / AWS_DEFAULT_REGION / us-east-1. */
region?: string
/** Endpoint override (tests). Defaults to the Bedrock control plane for the region. */
endpoint?: string
/** Clock override (tests) so the SigV4 signature is deterministic. */
now?: Date
}

function credentialsFromEnv(): AwsCredentials | null {
const accessKeyId = process.env.AWS_ACCESS_KEY_ID
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
if (!accessKeyId || !secretAccessKey) return null
return {
accessKeyId,
secretAccessKey,
sessionToken: process.env.AWS_SESSION_TOKEN,
}
}

function regionFromEnv(): string {
return process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? DEFAULT_REGION
}

function sha256Hex(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex')
}

function hmac(key: Buffer | string, value: string): Buffer {
return createHmac('sha256', key).update(value, 'utf8').digest()
}

/** Sign a GET request with AWS Signature Version 4 for the bedrock service. */
export function signBedrockRequest(opts: {
endpoint: string
region: string
credentials: AwsCredentials
now: Date
}): { url: string; headers: Record<string, string> } {
const url = new URL('/foundation-models', opts.endpoint)
const host = url.host
const amzDate = opts.now.toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)

const headers: Record<string, string> = {
host,
'x-amz-date': amzDate,
}
if (opts.credentials.sessionToken) {
headers['x-amz-security-token'] = opts.credentials.sessionToken
}
const signedHeaderNames = Object.keys(headers).sort()
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]}\n`).join('')
const signedHeaders = signedHeaderNames.join(';')
const payloadHash = sha256Hex('')

const canonicalRequest = ['GET', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
const scope = `${dateStamp}/${opts.region}/${SERVICE}/aws4_request`
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')

const kDate = hmac(`AWS4${opts.credentials.secretAccessKey}`, dateStamp)
const kRegion = hmac(kDate, opts.region)
const kService = hmac(kRegion, SERVICE)
const kSigning = hmac(kService, 'aws4_request')
const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex')

headers.Authorization =
`AWS4-HMAC-SHA256 Credential=${opts.credentials.accessKeyId}/${scope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${signature}`

return { url: url.toString(), headers }
}

/** Extract the AWS error code and message from an XML or JSON error body. */
export function parseAwsError(body: string): { code?: string; message?: string } {
const code = body.match(/<Code>([^<]+)<\/Code>/)?.[1] ?? body.match(/"__type"\s*:\s*"[^"#]*#([^"]+)"/)?.[1]
const message =
body.match(/<Message>([^<]+)<\/Message>/)?.[1] ??
body.match(/"message"\s*:\s*"([^"]+)"/i)?.[1]
return { code, message }
}

export async function probeBedrock(opts: BedrockProbeOptions = {}): Promise<ProbeResult> {
const checkedAt = new Date().toISOString()
const credentials = opts.credentials ?? credentialsFromEnv()

if (!credentials) {
return {
valid: false,
status: 'invalid',
detail:
'AWS error: NoCredentialProviders: no AWS credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or configure the standard AWS credential chain.',
checkedAt,
}
}

const region = opts.region ?? regionFromEnv()
const endpoint = opts.endpoint ?? `https://bedrock.${region}.amazonaws.com`
const { url, headers } = signBedrockRequest({
endpoint,
region,
credentials,
now: opts.now ?? new Date(),
})

const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(url, { method: 'GET', headers, signal: controller.signal })
if (res.ok) {
return { valid: true, status: 'ok', detail: 'Credentials validated against Bedrock ListFoundationModels.', checkedAt }
}
const body = await res.text().catch(() => '')
const { code, message } = parseAwsError(body)
const awsDetail = code ? `AWS error: ${code}${message ? `: ${message}` : ''}` : `AWS error: Unexpected HTTP ${res.status}.`
if (res.status === 429 || code === 'ThrottlingException') {
return { valid: true, status: 'quota_exceeded', detail: awsDetail, checkedAt }
}
if (res.status === 401 || res.status === 403) {
return { valid: false, status: 'invalid', detail: awsDetail, checkedAt }
}
return { valid: false, status: 'error', detail: awsDetail, checkedAt }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('abort')) {
return { valid: false, status: 'error', detail: `Timed out after ${TIMEOUT_MS}ms.`, checkedAt }
}
return { valid: false, status: 'error', detail: `AWS error: ${message}`, checkedAt }
} finally {
clearTimeout(timer)
}
}
59 changes: 59 additions & 0 deletions src/main/probes/azure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ProbeResult } from './types'

const TIMEOUT_MS = 5000
const DEFAULT_API_VERSION = '2024-02-01'

export interface AzureProbeOptions {
apiKey: string
/** Azure OpenAI resource endpoint, for example https://<resource>.openai.azure.com */
baseUrl?: string
apiVersion?: string
}

export async function probeAzure(opts: AzureProbeOptions): Promise<ProbeResult> {
const checkedAt = new Date().toISOString()

if (!opts.baseUrl) {
return {
valid: false,
status: 'error',
detail: 'Azure probe requires an endpoint. Set the Azure OpenAI endpoint (AZURE_OPENAI_ENDPOINT) for this key first.',
checkedAt,
}
}

if (!opts.apiKey) {
return { valid: false, status: 'invalid', detail: 'No API key supplied.', checkedAt }
}

const apiVersion = opts.apiVersion ?? DEFAULT_API_VERSION
const url = `${opts.baseUrl.replace(/\/$/, '')}/openai/deployments?api-version=${apiVersion}`

const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(url, {
method: 'GET',
headers: { 'api-key': opts.apiKey },
signal: controller.signal,
})
if (res.ok) {
return { valid: true, status: 'ok', detail: 'Key validated against Azure OpenAI deployments.', checkedAt }
}
if (res.status === 401 || res.status === 403) {
return { valid: false, status: 'invalid', detail: `Authentication failed (${res.status}).`, checkedAt }
}
if (res.status === 429) {
return { valid: true, status: 'quota_exceeded', detail: 'Rate limited; credential accepted.', checkedAt }
}
return { valid: false, status: 'error', detail: `Unexpected HTTP ${res.status}.`, checkedAt }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('abort')) {
return { valid: false, status: 'error', detail: `Timed out after ${TIMEOUT_MS}ms.`, checkedAt }
}
return { valid: false, status: 'error', detail: message, checkedAt }
} finally {
clearTimeout(timer)
}
}
Loading
Loading