From 991a24677ab5f16872fe8543305a5fc71a54dbed Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 3 Sep 2026 20:23:02 -0700 Subject: [PATCH] feat(codelayer): support Amazon Bedrock auth HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a06991-1550-77e1-ab0c-7c024694795e --- agents/codelayer/package.json | 6 + agents/codelayer/src/codex/bedrock-auth.ts | 168 ++++++++++++++ agents/codelayer/src/codex/codex-config.ts | 67 ++++++ agents/codelayer/src/codex/connection.ts | 139 +++++++++++ agents/codelayer/src/index.ts | 33 ++- agents/codelayer/src/providers.ts | 146 +++++++----- agents/codelayer/test/agent.test.ts | 48 ++-- agents/codelayer/test/bedrock-auth.test.ts | 217 ++++++++++++++++++ .../codelayer/test/codex-connection.test.ts | 211 +++++++++++++++++ agents/codelayer/test/providers.test.ts | 60 ++++- bun.lock | 80 ++++++- package.json | 10 +- packages/agentlayer-provider-auth/src/auth.ts | 50 +++- .../test/auth-disk.test.ts | 87 +++++++ .../providers/sse-vendor-provider/index.ts | 3 + .../websockets-vendor-provider/index.ts | 3 + .../test/ws-connection-limit.manual-test.ts | 1 + 17 files changed, 1236 insertions(+), 93 deletions(-) create mode 100644 agents/codelayer/src/codex/bedrock-auth.ts create mode 100644 agents/codelayer/src/codex/codex-config.ts create mode 100644 agents/codelayer/src/codex/connection.ts create mode 100644 agents/codelayer/test/bedrock-auth.test.ts create mode 100644 agents/codelayer/test/codex-connection.test.ts diff --git a/agents/codelayer/package.json b/agents/codelayer/package.json index 0e8f34e..e6800c4 100644 --- a/agents/codelayer/package.json +++ b/agents/codelayer/package.json @@ -46,14 +46,20 @@ "dependencies": { "@ai-sdk/anthropic": "catalog:", "@ai-sdk/openai": "catalog:", + "@aws/bedrock-token-generator": "catalog:", + "@aws-sdk/credential-providers": "catalog:", "@humanlayer/agentlayer-core": "workspace:*", "@humanlayer/agentlayer-filesystem": "workspace:*", "@humanlayer/agentlayer-provider-auth": "workspace:*", "@humanlayer/agentlayer-provider-github-copilot": "workspace:*", "@humanlayer/agentlayer-provider-openai-codex": "workspace:*", + "@smithy/types": "catalog:", + "@smithy/config-resolver": "catalog:", + "@smithy/node-config-provider": "catalog:", "ai": "catalog:", "chalk": "^5.6.2", "commander": "^14.0.3", + "smol-toml": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/agents/codelayer/src/codex/bedrock-auth.ts b/agents/codelayer/src/codex/bedrock-auth.ts new file mode 100644 index 0000000..2a22c81 --- /dev/null +++ b/agents/codelayer/src/codex/bedrock-auth.ts @@ -0,0 +1,168 @@ +import { getToken as generateBedrockToken } from '@aws/bedrock-token-generator' +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers' +import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types' + +const TOKEN_LIFETIME_SECONDS = 12 * 60 * 60 +const REFRESH_BUFFER_MS = 5 * 60 * 1000 + +export interface BedrockAuth { + getToken(): Promise + invalidate(): void +} + +export interface BedrockAuthDependencies { + now?: () => number + credentialProviderFactory?: (profile?: string) => AwsCredentialIdentityProvider + tokenGenerator?: (options: { + credentials: AwsCredentialIdentity + region: string + expiresInSeconds: number + }) => Promise +} + +export interface MakeBedrockAuthOptions extends BedrockAuthDependencies { + profile?: string + region: string +} + +export type BedrockFetch = (input: string | URL | Request, init?: RequestInit) => Promise + +export class BedrockCredentialsUnavailableError extends Error { + constructor(profile?: string) { + super( + profile + ? `AWS credentials for profile "${profile}" are unavailable or expired. Refresh the profile with your normal AWS login command, then retry this prompt. HumanLayer does not need to restart.` + : 'AWS credentials are unavailable or expired. Refresh them with your normal AWS login command, then retry this prompt. HumanLayer does not need to restart.', + ) + this.name = 'BedrockCredentialsUnavailableError' + } +} + +export function makeBedrockAuth(options: MakeBedrockAuthOptions): BedrockAuth { + const now = options.now ?? Date.now + const providerFactory = options.credentialProviderFactory ?? ((profile) => + profile + ? fromIni({ profile, clientConfig: { region: options.region } }) + : fromNodeProviderChain({ clientConfig: { region: options.region } })) + const tokenGenerator = options.tokenGenerator ?? generateBedrockToken + let credentialProvider: AwsCredentialIdentityProvider | undefined + let token: string | undefined + let refreshAt: number | undefined + let refresh: Promise | undefined + let generation = 0 + + const invalidate = () => { + generation++ + credentialProvider = undefined + token = undefined + refreshAt = undefined + refresh = undefined + } + + const performRefresh = async (refreshGeneration: number): Promise => { + try { + const provider = credentialProvider ?? providerFactory(options.profile) + if (refreshGeneration === generation) credentialProvider = provider + const credentials = await provider() + const generatedAt = now() + const generatedToken = await tokenGenerator({ + credentials, + region: options.region, + expiresInSeconds: TOKEN_LIFETIME_SECONDS, + }) + const tokenExpiry = generatedAt + TOKEN_LIFETIME_SECONDS * 1000 + const credentialExpiry = credentials.expiration?.getTime() + const effectiveExpiry = credentialExpiry === undefined + ? tokenExpiry + : Math.min(tokenExpiry, credentialExpiry) + const remaining = Math.max(0, effectiveExpiry - generatedAt) + if (refreshGeneration !== generation) return getToken() + token = generatedToken + refreshAt = remaining <= REFRESH_BUFFER_MS + ? generatedAt + remaining / 2 + : effectiveExpiry - REFRESH_BUFFER_MS + return generatedToken + } catch { + if (refreshGeneration !== generation) return getToken() + invalidate() + throw new BedrockCredentialsUnavailableError(options.profile) + } + } + + const getToken = async (): Promise => { + if (token !== undefined && refreshAt !== undefined && now() < refreshAt) return token + if (refresh) return refresh + const refreshGeneration = generation + const nextRefresh = performRefresh(refreshGeneration) + refresh = nextRefresh + void nextRefresh.finally(() => { + if (refresh === nextRefresh) refresh = undefined + }).catch(() => {}) + return nextRefresh + } + + return { + getToken, + invalidate, + } +} + +export async function isBedrockAuthenticationFailure(response: Response): Promise { + if (response.status === 401) return true + if (response.status !== 403) return false + try { + const body = await readResponsePrefix(response, 16_384) + return ['ExpiredToken', 'UnrecognizedClientException', 'InvalidClientTokenId'].some((code) => + body.includes(code)) + } catch { + return false + } +} + +async function readResponsePrefix(response: Response, maximumBytes: number): Promise { + const body = response.clone().body + if (!body) return '' + const reader = body.getReader() + const decoder = new TextDecoder() + let result = '' + let bytesRead = 0 + try { + while (bytesRead < maximumBytes) { + const { done, value } = await reader.read() + if (done) break + const remaining = maximumBytes - bytesRead + const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value + bytesRead += chunk.byteLength + result += decoder.decode(chunk, { stream: bytesRead < maximumBytes }) + } + result += decoder.decode() + return result + } finally { + if (bytesRead >= maximumBytes) void reader.cancel().catch(() => {}) + reader.releaseLock() + } +} + +export async function fetchWithBedrockAuth( + auth: BedrockAuth, + fetch: BedrockFetch, + input: string | URL | Request, + init?: RequestInit, +): Promise { + const headers = new Headers(input instanceof Request ? input.headers : undefined) + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) + const request = input instanceof Request + ? new Request(input, { ...init, headers }) + : new Request(input.toString(), { ...init, headers }) + const attempts = [request.clone() as Request, request.clone() as Request] + const send = async (attempt: Request) => { + const headers = new Headers(attempt.headers) + headers.set('authorization', `Bearer ${await auth.getToken()}`) + return fetch(new Request(attempt, { headers })) + } + + const first = await send(attempts[0]!) + if (!(await isBedrockAuthenticationFailure(first))) return first + auth.invalidate() + return send(attempts[1]!) +} diff --git a/agents/codelayer/src/codex/codex-config.ts b/agents/codelayer/src/codex/codex-config.ts new file mode 100644 index 0000000..47704c3 --- /dev/null +++ b/agents/codelayer/src/codex/codex-config.ts @@ -0,0 +1,67 @@ +import * as fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { parse } from 'smol-toml' + +export interface CodexBedrockConfig { + modelProvider?: string + model?: string + profile?: string + region?: string + baseUrl?: string +} + +export interface ReadCodexConfigOptions { + codexHome?: string + env?: NodeJS.ProcessEnv + homeDirectory?: string +} + +export function getCodexConfigPath(options: ReadCodexConfigOptions = {}): string { + const codexHome = options.codexHome ?? (options.env ?? process.env).CODEX_HOME + return path.join(codexHome ?? path.join(options.homeDirectory ?? os.homedir(), '.codex'), 'config.toml') +} + +export async function readCodexBedrockConfig( + options: ReadCodexConfigOptions = {}, +): Promise { + let source: string + try { + source = await fs.readFile(getCodexConfigPath(options), 'utf8') + } catch (error) { + if (isNotFoundError(error)) return undefined + throw error + } + + let document: unknown + try { + document = parse(source) + } catch { + throw new Error('Codex config.toml is malformed.') + } + if (!isRecord(document)) return {} + const provider = isRecord(document.model_providers) + ? document.model_providers['amazon-bedrock'] + : undefined + const aws = isRecord(provider) ? provider.aws : undefined + + return { + ...optionalString('modelProvider', document.model_provider), + ...optionalString('model', document.model), + ...(isRecord(aws) ? optionalString('profile', aws.profile) : {}), + ...(isRecord(aws) ? optionalString('region', aws.region) : {}), + ...(isRecord(provider) ? optionalString('baseUrl', provider.base_url) : {}), + } +} + +function optionalString(key: string, value: unknown): Record { + return typeof value === 'string' && value.length > 0 ? { [key]: value } : {} +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNotFoundError(error: unknown): boolean { + return isRecord(error) && error.code === 'ENOENT' +} diff --git a/agents/codelayer/src/codex/connection.ts b/agents/codelayer/src/codex/connection.ts new file mode 100644 index 0000000..3d6a9ff --- /dev/null +++ b/agents/codelayer/src/codex/connection.ts @@ -0,0 +1,139 @@ +import { + InvalidAuthEntryError, + type AuthStore, + type AwsProfileAuthInfo, +} from '@humanlayer/agentlayer-provider-auth' +import { NODE_REGION_CONFIG_FILE_OPTIONS, NODE_REGION_CONFIG_OPTIONS } from '@smithy/config-resolver' +import { loadConfig } from '@smithy/node-config-provider' +import { readCodexBedrockConfig } from './codex-config' + +export type CodexConnection = + | { readonly type: 'chatgpt' } + | { + readonly type: 'bedrock' + readonly profile?: string + readonly region?: string + readonly model?: string + readonly baseURL?: string + } + +export type ResolvedCodexConnection = + | { type: 'chatgpt' } + | { type: 'custom-responses' } + | { type: 'bedrock'; profile?: string; region: string; model: string; baseURL: string; endpointURL: string } + +export interface ResolveCodexConnectionOptions { + explicitConnection?: CodexConnection + authStore: AuthStore + selectedModelId: string + codexHome?: string + env?: NodeJS.ProcessEnv + homeDirectory?: string + hasLegacyOverride?: boolean + regionProvider?: (profile?: string) => Promise +} + +export async function resolveCodexConnection( + options: ResolveCodexConnectionOptions, +): Promise { + const env = options.env ?? process.env + if (options.explicitConnection) { + return options.explicitConnection.type === 'chatgpt' + ? { type: 'chatgpt' } + : resolveBedrock(options.explicitConnection, options.selectedModelId, options.regionProvider) + } + + const stored = await options.authStore.get('codex_bedrock') + if (stored && stored.kind !== 'aws-profile') throw new InvalidAuthEntryError('codex_bedrock') + if (stored?.kind === 'aws-profile') { + if (stored.active === true) { + return resolveBedrock(fromStored(stored), options.selectedModelId, options.regionProvider) + } + if (stored.active === false) return { type: 'chatgpt' } + } + if (options.hasLegacyOverride) return { type: 'custom-responses' } + + const config = await readCodexBedrockConfig({ + codexHome: options.codexHome, + env, + homeDirectory: options.homeDirectory, + }) + if (config?.modelProvider === 'amazon-bedrock-runtime') { + throw new Error('Codex model provider "amazon-bedrock-runtime" is not supported; use "amazon-bedrock".') + } + if (config?.modelProvider !== 'amazon-bedrock') return { type: 'chatgpt' } + return resolveBedrock({ + type: 'bedrock', + profile: stored?.profile ?? config.profile, + region: stored?.region ?? config.region, + model: stored?.model ?? config.model, + baseURL: stored?.baseUrl ?? config.baseUrl, + }, options.selectedModelId, options.regionProvider) +} + +function fromStored(auth: AwsProfileAuthInfo): Extract { + return { + type: 'bedrock', + profile: auth.profile, + region: auth.region, + model: auth.model, + baseURL: auth.baseUrl, + } +} + +async function resolveBedrock( + connection: Extract, + selectedModelId: string, + regionProvider: (profile?: string) => Promise = resolveAwsRegion, +): Promise { + let region = connection.region + if (!region) { + try { + region = await regionProvider(connection.profile) + } catch { + // Normalize the SDK's missing-region errors without exposing config contents. + } + } + if (!region) { + throw new Error('Amazon Bedrock configuration is incomplete: an AWS region could not be resolved.') + } + const baseURL = connection.baseURL ?? `https://bedrock-mantle.${region}.api.aws/openai/v1` + const endpoint = parseResponsesURL(baseURL, 'Amazon Bedrock base URL') + const model = connection.model ?? (selectedModelId.startsWith('openai.') ? selectedModelId : `openai.${selectedModelId}`) + return { type: 'bedrock', profile: connection.profile, region, model, ...endpoint } +} + +export function parseResponsesURL(rawValue: string, settingName: string): { baseURL: string; endpointURL: string } { + let url: URL + try { + url = new URL(rawValue) + } catch { + throw new Error(`${settingName} must be an absolute HTTP or HTTPS URL.`) + } + if (url.username || url.password) throw new Error(`${settingName} must not contain a username or password.`) + if (url.search || url.hash) throw new Error(`${settingName} must not contain a query string or fragment.`) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) { + throw new Error(`${settingName} must use HTTPS unless it points to a loopback host.`) + } + const normalizedPath = url.pathname.replace(/\/+$/, '') + const isFullEndpoint = normalizedPath.endsWith('/responses') + const basePath = isFullEndpoint ? normalizedPath.slice(0, -'/responses'.length) : normalizedPath + url.pathname = basePath || '/' + const baseURL = url.toString().replace(/\/$/, '') + url.pathname = `${basePath}/responses` || '/responses' + return { baseURL, endpointURL: url.toString() } +} + +async function resolveAwsRegion(profile?: string): Promise { + return loadConfig(NODE_REGION_CONFIG_OPTIONS, { + ...NODE_REGION_CONFIG_FILE_OPTIONS, + profile, + })() +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized === '[::1]') return true + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized) + return match !== null && Number(match[1]) === 127 && match.slice(1).every((part) => Number(part) <= 255) +} diff --git a/agents/codelayer/src/index.ts b/agents/codelayer/src/index.ts index c42fd93..5f41c27 100644 --- a/agents/codelayer/src/index.ts +++ b/agents/codelayer/src/index.ts @@ -10,4 +10,35 @@ export { } from './agent' export { type CodelayerCliOptions, createCodelayerCommand, parseProviderOptionOverrides } from './command' export { createCodingSubagentTool, type CreateCodingSubagentToolOptions } from './coding-subagent-tool' -export { DEFAULT_MODELS, type ProviderType, type ResolveModelContext, resolveExaApiKey, resolveModel } from './providers' +export { + DEFAULT_MODELS, + type CodexResponsesAuth, + type CodexResponsesOverride, + type ProviderType, + type ResolveModelContext, + createCustomCodexResponsesModel, + resolveExaApiKey, + resolveModel, +} from './providers' +export { + BedrockCredentialsUnavailableError, + fetchWithBedrockAuth, + isBedrockAuthenticationFailure, + makeBedrockAuth, + type BedrockAuth, + type BedrockAuthDependencies, + type MakeBedrockAuthOptions, +} from './codex/bedrock-auth' +export { + parseResponsesURL, + resolveCodexConnection, + type CodexConnection, + type ResolveCodexConnectionOptions, + type ResolvedCodexConnection, +} from './codex/connection' +export { + getCodexConfigPath, + readCodexBedrockConfig, + type CodexBedrockConfig, + type ReadCodexConfigOptions, +} from './codex/codex-config' diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index 04f1a9f..3ab02cd 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -1,7 +1,7 @@ import { createAnthropic } from '@ai-sdk/anthropic' import { createOpenAI } from '@ai-sdk/openai' import type { LanguageModel } from 'ai' -import { ensureFileAuthStore, type AuthInfo } from '@humanlayer/agentlayer-provider-auth' +import { ensureFileAuthStore, type AuthInfo, type AuthStore } from '@humanlayer/agentlayer-provider-auth' import { createCopilotProvider } from '@humanlayer/agentlayer-provider-github-copilot' import { createCodexSseVendorProvider, @@ -9,6 +9,12 @@ import { type CodexDiagnosticsContext, CODEX_DEFAULT_VERSION, } from '@humanlayer/agentlayer-provider-openai-codex' +import { fetchWithBedrockAuth, makeBedrockAuth, type BedrockAuth } from './codex/bedrock-auth' +import { + parseResponsesURL, + resolveCodexConnection, + type CodexConnection, +} from './codex/connection' export type CodexProviderMode = 'sse' | 'websockets' @@ -23,12 +29,23 @@ export type ProviderType = 'anthropic' | 'openai' | 'codex' | 'copilot' | 'firep export interface ResolveModelContext { codexDiagnostics?: CodexDiagnosticsContext codexProviderMode?: CodexProviderMode + codexConnection?: CodexConnection + codexHome?: string + /** Override the default file store, primarily for embedded runtimes and tests. */ + authStore?: AuthStore } +export type CodexResponsesAuth = + | { type: 'static'; apiKey: string; header?: string } + | { type: 'bedrock'; auth: BedrockAuth } + export interface CodexResponsesOverride { baseURL: string endpointURL: string - apiKey: string + auth?: CodexResponsesAuth + /** @deprecated Use auth: { type: 'static', apiKey, header }. */ + apiKey?: string + /** @deprecated Use auth.header. */ apiKeyHeader?: string wireModelId?: string } @@ -173,44 +190,8 @@ function optionalEnvironmentValue(value: string | undefined): string | undefined return value === undefined || value.length === 0 ? undefined : value } -function isLoopbackHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase() - if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized === '[::1]') return true - const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized) - return match !== null && Number(match[1]) === 127 && match.slice(1).every((part) => Number(part) <= 255) -} - function parseCodexResponsesURL(rawValue: string): Pick { - let url: URL - try { - url = new URL(rawValue) - } catch { - throw new Error(`${CODEX_OVERRIDE_ENV.baseURL} must be an absolute HTTP or HTTPS URL.`) - } - - if (url.username || url.password) { - throw new Error(`${CODEX_OVERRIDE_ENV.baseURL} must not contain a username or password.`) - } - if (url.search || url.hash) { - throw new Error(`${CODEX_OVERRIDE_ENV.baseURL} must not contain a query string or fragment.`) - } - if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) { - throw new Error(`${CODEX_OVERRIDE_ENV.baseURL} must use HTTPS unless it points to a loopback host.`) - } - - const normalizedPath = url.pathname.replace(/\/+$/, '') - const isFullEndpoint = normalizedPath.endsWith('/responses') - const basePath = isFullEndpoint ? normalizedPath.slice(0, -'/responses'.length) : normalizedPath - const endpointPath = `${basePath}/responses` || '/responses' - - url.pathname = basePath || '/' - const baseURL = url.toString().replace(/\/$/, '') - url.pathname = endpointPath - - return { - baseURL, - endpointURL: url.toString(), - } + return parseResponsesURL(rawValue, CODEX_OVERRIDE_ENV.baseURL) } function validateHeaderName(headerName: string): void { @@ -248,6 +229,7 @@ export function readCodexResponsesOverride( return { ...parseCodexResponsesURL(rawBaseURL), + auth: { type: 'static', apiKey, header: apiKeyHeader }, apiKey, apiKeyHeader, wireModelId, @@ -259,11 +241,14 @@ function reportCustomResponsesError(options: { diagnostics?: CodexDiagnosticsContext error: unknown operation: 'generate' | 'resolve' | 'stream' + redactErrorMessage?: boolean }): void { if (!options.diagnostics) return const error = options.error instanceof Error ? options.error : new Error(String(options.error)) - const safeMessage = options.apiKey ? error.message.replaceAll(options.apiKey, '[REDACTED]') : error.message + const safeMessage = options.redactErrorMessage + ? 'Amazon Bedrock Responses request failed.' + : options.apiKey ? error.message.replaceAll(options.apiKey, '[REDACTED]') : error.message const statusCode = 'statusCode' in error && typeof error.statusCode === 'number' ? error.statusCode : undefined try { options.diagnostics.onEvent({ @@ -291,20 +276,31 @@ export function createCustomCodexResponsesModel(options: { diagnostics?: CodexDiagnosticsContext }): LanguageModel { const { override, selectedModelId } = options + const auth = override.auth ?? (override.apiKey === undefined + ? undefined + : { type: 'static' as const, apiKey: override.apiKey, header: override.apiKeyHeader }) + if (!auth) throw new Error('Custom Codex Responses authentication is required.') + const staticApiKey = auth.type === 'static' ? auth.apiKey : undefined + const redactErrorMessage = auth.type === 'bedrock' const createDeploymentModel = (rawUsage: RawCacheUsage) => { const requestFetch = async (input: string | URL | Request, init?: RequestInit) => { - const headers = new Headers(init?.headers) - if (override.apiKeyHeader !== undefined) { - headers.delete('authorization') - headers.set(override.apiKeyHeader, override.apiKey) + let response: Response + if (auth.type === 'bedrock') { + response = await fetchWithBedrockAuth(auth.auth, options.fetch ?? globalThis.fetch, input, init) + } else { + const headers = new Headers(init?.headers) + if (auth.header !== undefined) { + headers.delete('authorization') + headers.set(auth.header, auth.apiKey) + } + response = await (options.fetch ?? globalThis.fetch)(input, { ...init, headers }) } - const response = await (options.fetch ?? globalThis.fetch)(input, { ...init, headers }) return await captureResponseUsage(response, rawUsage) } return createOpenAI({ name: 'custom-openai-responses', baseURL: override.baseURL, - apiKey: override.apiKey, + apiKey: staticApiKey ?? 'bedrock-auth-placeholder', fetch: requestFetch as typeof globalThis.fetch, }).responses(override.wireModelId ?? selectedModelId) } @@ -321,10 +317,11 @@ export function createCustomCodexResponsesModel(options: { return patchCacheUsage(await createDeploymentModel(rawUsage).doGenerate(request), rawUsage) } catch (error) { reportCustomResponsesError({ - apiKey: override.apiKey, + apiKey: staticApiKey, diagnostics: options.diagnostics, error, operation: 'generate', + redactErrorMessage, }) throw error } @@ -339,10 +336,11 @@ export function createCustomCodexResponsesModel(options: { transform(part, controller) { if (part.type === 'error') { reportCustomResponsesError({ - apiKey: override.apiKey, + apiKey: staticApiKey, diagnostics: options.diagnostics, error: part.error, operation: 'stream', + redactErrorMessage, }) } controller.enqueue(part.type === 'finish' ? patchCacheUsage(part, rawUsage) : part) @@ -351,10 +349,11 @@ export function createCustomCodexResponsesModel(options: { } } catch (error) { reportCustomResponsesError({ - apiKey: override.apiKey, + apiKey: staticApiKey, diagnostics: options.diagnostics, error, operation: 'stream', + redactErrorMessage, }) throw error } @@ -421,19 +420,47 @@ export async function resolveModel( return fireworks.chat(modelId) } case 'codex': { - let override: CodexResponsesOverride | undefined - try { - override = readCodexResponsesOverride() - } catch (error) { - reportCustomResponsesError({ - apiKey: process.env[CODEX_OVERRIDE_ENV.apiKey], + const authStore = context?.authStore ?? await ensureFileAuthStore() + const hasLegacyOverride = Object.values(CODEX_OVERRIDE_ENV).some((name) => + optionalEnvironmentValue(process.env[name]) !== undefined) + const connection = await resolveCodexConnection({ + explicitConnection: context?.codexConnection, + authStore, + selectedModelId: modelId, + codexHome: context?.codexHome, + hasLegacyOverride, + }) + if (connection.type === 'bedrock') { + return createCustomCodexResponsesModel({ + override: { + baseURL: connection.baseURL, + endpointURL: connection.endpointURL, + auth: { + type: 'bedrock', + auth: makeBedrockAuth({ profile: connection.profile, region: connection.region }), + }, + wireModelId: connection.model, + }, + selectedModelId: modelId, diagnostics: context?.codexDiagnostics, - error, - operation: 'resolve', }) - throw error } - if (override !== undefined) { + + let override: CodexResponsesOverride | undefined + if (connection.type === 'custom-responses') { + try { + override = readCodexResponsesOverride() + } catch (error) { + reportCustomResponsesError({ + apiKey: process.env[CODEX_OVERRIDE_ENV.apiKey], + diagnostics: context?.codexDiagnostics, + error, + operation: 'resolve', + }) + throw error + } + } + if (override) { return createCustomCodexResponsesModel({ override, selectedModelId: modelId, @@ -441,7 +468,6 @@ export async function resolveModel( }) } - const authStore = await ensureFileAuthStore() const requestedRaw = context?.codexProviderMode ?? (process.env.CODEX_PROVIDER as string | undefined) // An empty env value (CODEX_PROVIDER= from a template) means unset, not unknown. const requestedMode = requestedRaw === '' ? undefined : requestedRaw diff --git a/agents/codelayer/test/agent.test.ts b/agents/codelayer/test/agent.test.ts index 5ff3250..61a44b5 100644 --- a/agents/codelayer/test/agent.test.ts +++ b/agents/codelayer/test/agent.test.ts @@ -188,53 +188,47 @@ describe('provider resolution', () => { }) test('continues to resolve codex and copilot from AgentLayer auth store', async () => { - const codexModel = await resolveModel('codex', 'gpt-5.5') + const codexModel = await resolveModel('codex', 'gpt-5.5', { authStore }) const copilotModel = await resolveModel('copilot', 'gpt-5.4') expect(codexModel).toBeDefined() expect(copilotModel).toBeDefined() - expect(providerAuth.ensureFileAuthStore).toHaveBeenCalledTimes(2) + expect(providerAuth.ensureFileAuthStore).toHaveBeenCalledTimes(1) }) - test('resolves a complete custom Codex override before auth and private provider selection', async () => { + test('resolves a complete custom Codex override after checking active connection selection', async () => { process.env.CODELAYER_CODEX_BASE_URL = 'https://example.test/openai/v1' process.env.CODELAYER_CODEX_API_KEY = 'custom-test-key' process.env.CODELAYER_CODEX_MODEL = 'azure-coding-deployment' process.env.CODEX_PROVIDER = 'websockets' - const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider') - const websocketSpy = spyOn(codexProvider, 'createCodexEffectProvider') - const model = await resolveModel('codex', 'gpt-5.6-sol') + const model = await resolveModel('codex', 'gpt-5.6-sol', { authStore }) expect((model as { provider: string }).provider).toBe('custom-openai-responses') expect((model as { modelId: string }).modelId).toBe('gpt-5.6-sol') - expect(providerAuth.ensureFileAuthStore).not.toHaveBeenCalled() - expect(sseSpy).not.toHaveBeenCalled() - expect(websocketSpy).not.toHaveBeenCalled() }) - test('rejects partial custom Codex settings before auth or private provider selection', async () => { + test('rejects partial custom Codex settings after checking active connection selection', async () => { process.env.CODELAYER_CODEX_BASE_URL = 'https://example.test/openai/v1' - const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider') - const websocketSpy = spyOn(codexProvider, 'createCodexEffectProvider') - await expect(resolveModel('codex', 'gpt-5.6-sol')).rejects.toThrow('CODELAYER_CODEX_API_KEY') + await expect(resolveModel('codex', 'gpt-5.6-sol', { authStore })).rejects.toThrow('CODELAYER_CODEX_API_KEY') delete process.env.CODELAYER_CODEX_BASE_URL process.env.CODELAYER_CODEX_API_KEY = 'custom-test-key' - await expect(resolveModel('codex', 'gpt-5.6-sol')).rejects.toThrow('CODELAYER_CODEX_BASE_URL') - expect(providerAuth.ensureFileAuthStore).not.toHaveBeenCalled() - expect(sseSpy).not.toHaveBeenCalled() - expect(websocketSpy).not.toHaveBeenCalled() + await expect(resolveModel('codex', 'gpt-5.6-sol', { authStore })).rejects.toThrow('CODELAYER_CODEX_BASE_URL') }) test('keeps every private Codex transport available when the override is absent', async () => { const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider') const websocketSpy = spyOn(codexProvider, 'createCodexEffectProvider') - await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'sse' }) - await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'websockets' }) + await resolveModel('codex', 'gpt-5.5', { + codexProviderMode: 'sse', authStore, codexConnection: { type: 'chatgpt' }, + }) + await resolveModel('codex', 'gpt-5.5', { + codexProviderMode: 'websockets', authStore, codexConnection: { type: 'chatgpt' }, + }) - expect(providerAuth.ensureFileAuthStore).toHaveBeenCalledTimes(2) + expect(providerAuth.ensureFileAuthStore).not.toHaveBeenCalled() expect(sseSpy).toHaveBeenCalledTimes(1) expect(websocketSpy).toHaveBeenCalledTimes(1) }) @@ -242,7 +236,9 @@ describe('provider resolution', () => { test('defaults codex model resolution to the SSE provider', async () => { const providerSpy = spyOn(codexProvider, 'createCodexSseVendorProvider') - const model = await resolveModel('codex', 'gpt-5.5') + const model = await resolveModel('codex', 'gpt-5.5', { + authStore, codexConnection: { type: 'chatgpt' }, + }) expect(model).toBeDefined() expect(providerSpy).toHaveBeenCalled() @@ -255,7 +251,9 @@ describe('provider resolution', () => { onEvent: () => {}, } - const model = await resolveModel('codex', 'gpt-5.5', { codexDiagnostics }) + const model = await resolveModel('codex', 'gpt-5.5', { + codexDiagnostics, authStore, codexConnection: { type: 'chatgpt' }, + }) expect(model).toBeDefined() expect(providerSpy).toHaveBeenCalledWith( @@ -273,6 +271,8 @@ describe('provider resolution', () => { // stale context value must degrade to the default transport, not crash. const model = await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'aisdk_responses' as never, + authStore, + codexConnection: { type: 'chatgpt' }, }) expect(model).toBeDefined() @@ -283,7 +283,9 @@ describe('provider resolution', () => { process.env.CODEX_PROVIDER = 'websockets' const providerSpy = spyOn(codexProvider, 'createCodexEffectProvider') - const model = await resolveModel('codex', 'gpt-5.5') + const model = await resolveModel('codex', 'gpt-5.5', { + authStore, codexConnection: { type: 'chatgpt' }, + }) expect(model).toBeDefined() expect(providerSpy).toHaveBeenCalled() diff --git a/agents/codelayer/test/bedrock-auth.test.ts b/agents/codelayer/test/bedrock-auth.test.ts new file mode 100644 index 0000000..2a27abe --- /dev/null +++ b/agents/codelayer/test/bedrock-auth.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { AwsCredentialIdentity } from '@smithy/types' +import { + fetchWithBedrockAuth, + makeBedrockAuth, + type BedrockAuth, +} from '../src/codex/bedrock-auth' + +const credentials: AwsCredentialIdentity = { + accessKeyId: 'secret-access-id', + secretAccessKey: 'secret-access-key', + sessionToken: 'secret-session-token', +} + +describe('makeBedrockAuth', () => { + test('caches, expires, and single-flights generated tokens', async () => { + let now = 1_000_000 + let release: (() => void) | undefined + const barrier = new Promise((resolve) => { release = resolve }) + const provider = mock(async () => credentials) + const tokenGenerator = mock(async () => { + await barrier + return `token-${tokenGenerator.mock.calls.length}` + }) + const auth = makeBedrockAuth({ + region: 'us-east-1', + now: () => now, + credentialProviderFactory: () => provider, + tokenGenerator, + }) + + const pending = [auth.getToken(), auth.getToken(), auth.getToken()] + release!() + expect(await Promise.all(pending)).toEqual(['token-1', 'token-1', 'token-1']) + expect(tokenGenerator).toHaveBeenCalledTimes(1) + expect(await auth.getToken()).toBe('token-1') + + now += 12 * 60 * 60 * 1000 - 5 * 60 * 1000 + expect(await auth.getToken()).toBe('token-2') + expect(tokenGenerator).toHaveBeenCalledTimes(2) + }) + + test('uses AWS credential expiration to shorten cache lifetime', async () => { + let now = 5_000_000 + const tokenGenerator = mock(async () => `token-${tokenGenerator.mock.calls.length}`) + const auth = makeBedrockAuth({ + region: 'us-east-1', + now: () => now, + credentialProviderFactory: () => async () => ({ + ...credentials, + expiration: new Date(5_000_000 + 10 * 60 * 1000), + }), + tokenGenerator, + }) + + expect(await auth.getToken()).toBe('token-1') + now += 5 * 60 * 1000 + expect(await auth.getToken()).toBe('token-2') + }) + + test('rebuilds provider after invalidation and redacts credential failures', async () => { + const providerFactory = mock(() => async () => credentials) + const tokenGenerator = mock(async () => 'token') + const auth = makeBedrockAuth({ + profile: 'work', + region: 'us-east-1', + credentialProviderFactory: providerFactory, + tokenGenerator, + }) + await auth.getToken() + auth.invalidate() + await auth.getToken() + expect(providerFactory).toHaveBeenCalledTimes(2) + + const failing = makeBedrockAuth({ + profile: 'work', + region: 'us-east-1', + credentialProviderFactory: () => async () => { throw new Error(JSON.stringify(credentials)) }, + }) + let message = '' + try { await failing.getToken() } catch (error) { message = String(error) } + expect(message).toContain('profile "work"') + for (const secret of Object.values(credentials)) expect(message).not.toContain(String(secret)) + }) + + test('invalidation supersedes an in-flight refresh without publishing its stale token', async () => { + let releaseStale: ((token: string) => void) | undefined + let releaseCurrent: ((token: string) => void) | undefined + const staleToken = new Promise((resolve) => { releaseStale = resolve }) + const currentToken = new Promise((resolve) => { releaseCurrent = resolve }) + const providerFactory = mock(() => async () => credentials) + const tokenGenerator = mock(async () => + tokenGenerator.mock.calls.length === 1 ? staleToken : currentToken) + const auth = makeBedrockAuth({ + region: 'us-east-1', + credentialProviderFactory: providerFactory, + tokenGenerator, + }) + + const staleRequest = auth.getToken() + while (tokenGenerator.mock.calls.length < 1) await Promise.resolve() + auth.invalidate() + const currentRequest = auth.getToken() + while (tokenGenerator.mock.calls.length < 2) await Promise.resolve() + releaseStale!('stale-token') + const joinedRequest = auth.getToken() + expect(tokenGenerator).toHaveBeenCalledTimes(2) + releaseCurrent!('current-token') + + expect(await Promise.all([staleRequest, currentRequest, joinedRequest])).toEqual([ + 'current-token', + 'current-token', + 'current-token', + ]) + expect(await auth.getToken()).toBe('current-token') + expect(providerFactory).toHaveBeenCalledTimes(2) + }) +}) + +describe('fetchWithBedrockAuth', () => { + function auth(tokens: string[]): BedrockAuth & { invalidations: number } { + return { + invalidations: 0, + async getToken() { return tokens.shift() ?? 'last-token' }, + invalidate() { this.invalidations++ }, + } + } + + test('replaces placeholder authorization and preserves successful streaming bodies', async () => { + const bedrockAuth = auth(['bedrock-token']) + const body = new ReadableStream({ start(controller) { controller.enqueue('chunk'); controller.close() } }) + const requestFetch = mock(async (input: string | URL | Request) => { + expect(input).toBeInstanceOf(Request) + expect(new Headers((input as Request).headers).get('authorization')).toBe('Bearer bedrock-token') + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) + }) + const response = await fetchWithBedrockAuth(bedrockAuth, requestFetch, 'https://example.test', { + headers: { authorization: 'Bearer placeholder' }, + }) + expect(await response.text()).toBe('chunk') + expect(requestFetch).toHaveBeenCalledTimes(1) + }) + + test('preserves Request and init headers and replays a Request body on auth retry', async () => { + const bedrockAuth = auth(['old-token', 'new-token']) + const attempts: Array<{ body: string; headers: Headers }> = [] + const requestFetch = mock(async (input: string | URL | Request) => { + const request = input as Request + attempts.push({ body: await request.text(), headers: request.headers }) + return attempts.length === 1 ? new Response('', { status: 401 }) : new Response('ok') + }) + const request = new Request('https://example.test/responses', { + method: 'POST', + headers: { authorization: 'Bearer placeholder', 'x-request-header': 'request' }, + body: 'replayable payload', + }) + + const response = await fetchWithBedrockAuth(bedrockAuth, requestFetch, request, { + headers: { 'x-init-header': 'init' }, + }) + + expect(await response.text()).toBe('ok') + expect(attempts.map((attempt) => attempt.body)).toEqual(['replayable payload', 'replayable payload']) + expect(attempts.map((attempt) => attempt.headers.get('authorization'))).toEqual([ + 'Bearer old-token', + 'Bearer new-token', + ]) + for (const attempt of attempts) { + expect(attempt.headers.get('x-request-header')).toBe('request') + expect(attempt.headers.get('x-init-header')).toBe('init') + } + }) + + test.each([ + new Response('', { status: 401 }), + new Response(JSON.stringify({ code: 'ExpiredToken' }), { status: 403 }), + ])('invalidates and retries one qualifying auth failure', async (first) => { + const bedrockAuth = auth(['old-token', 'new-token']) + const responses = [first, new Response('still forbidden', { status: 401 })] + const requestFetch = mock(async () => responses.shift()!) + const response = await fetchWithBedrockAuth(bedrockAuth, requestFetch, 'https://example.test') + expect(response.status).toBe(401) + expect(bedrockAuth.invalidations).toBe(1) + expect(requestFetch).toHaveBeenCalledTimes(2) + }) + + test('does not retry unrelated forbidden responses', async () => { + const bedrockAuth = auth(['token']) + const requestFetch = mock(async () => new Response('AccessDeniedException', { status: 403 })) + expect((await fetchWithBedrockAuth(bedrockAuth, requestFetch, 'https://example.test')).status).toBe(403) + expect(requestFetch).toHaveBeenCalledTimes(1) + expect(bedrockAuth.invalidations).toBe(0) + }) + + test('bounds forbidden-response inspection without waiting for the full body', async () => { + let pulls = 0 + const body = new ReadableStream({ + pull(controller) { + pulls++ + if (pulls === 1) controller.enqueue(new Uint8Array(16_384).fill(65)) + // A second pull intentionally never closes. Full buffering would hang. + }, + }) + const bedrockAuth = auth(['token']) + const requestFetch = mock(async () => new Response(body, { status: 403 })) + const result = await Promise.race([ + fetchWithBedrockAuth(bedrockAuth, requestFetch, 'https://example.test'), + Bun.sleep(500).then(() => { throw new Error('403 inspection did not stop at its byte limit') }), + ]) + + expect(result.status).toBe(403) + expect(requestFetch).toHaveBeenCalledTimes(1) + // Response.clone() may prefetch for each tee branch, but inspection still + // returns after the first bounded chunk instead of draining the stream. + expect(pulls).toBeLessThanOrEqual(3) + }) +}) diff --git a/agents/codelayer/test/codex-connection.test.ts b/agents/codelayer/test/codex-connection.test.ts new file mode 100644 index 0000000..8f0c6bf --- /dev/null +++ b/agents/codelayer/test/codex-connection.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, mock, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { createFileAuthStore, createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' +import { getCodexConfigPath, readCodexBedrockConfig } from '../src/codex/codex-config' +import { resolveCodexConnection, type CodexConnection } from '../src/codex/connection' + +async function tempDirectory(): Promise { + return fs.mkdtemp(path.join(tmpdir(), 'codelayer-codex-')) +} + +describe('Codex config and connection resolution', () => { + test('parses the supported amazon-bedrock fields from CODEX_HOME', async () => { + const home = await tempDirectory() + await fs.writeFile(path.join(home, 'config.toml'), ` +model_provider = "amazon-bedrock" +model = "openai.gpt-5.6-sol" +[model_providers.amazon-bedrock] +base_url = "https://bedrock.example.test/openai/v1" +[model_providers.amazon-bedrock.aws] +profile = "work" +region = "us-west-2" +`) + expect(getCodexConfigPath({ codexHome: home })).toBe(path.join(home, 'config.toml')) + expect(await readCodexBedrockConfig({ codexHome: home })).toEqual({ + modelProvider: 'amazon-bedrock', + model: 'openai.gpt-5.6-sol', + profile: 'work', + region: 'us-west-2', + baseUrl: 'https://bedrock.example.test/openai/v1', + }) + }) + + test('$CODEX_HOME overrides the default ~/.codex config path', async () => { + const homeDirectory = await tempDirectory() + const codexHome = await tempDirectory() + await fs.mkdir(path.join(homeDirectory, '.codex')) + await fs.writeFile(path.join(homeDirectory, '.codex', 'config.toml'), 'model = "home-model"\n') + await fs.writeFile(path.join(codexHome, 'config.toml'), 'model = "codex-home-model"\n') + + expect(await readCodexBedrockConfig({ + env: { CODEX_HOME: codexHome }, + homeDirectory, + })).toEqual({ model: 'codex-home-model' }) + }) + + test('applies explicit, active marker, legacy override, config, and fallback precedence', async () => { + const codexHome = await tempDirectory() + await fs.writeFile(path.join(codexHome, 'config.toml'), ` +model_provider = "amazon-bedrock" +model = "config-model" +[model_providers.amazon-bedrock.aws] +region = "us-west-2" +`) + const explicit: CodexConnection = { type: 'bedrock', region: 'eu-west-1', model: 'explicit-model' } + const common = { selectedModelId: 'gpt-5.6-sol', codexHome, env: {} } + + expect(await resolveCodexConnection({ + ...common, + explicitConnection: explicit, + authStore: createMemoryAuthStore({ codex_bedrock: { kind: 'aws-profile', active: false } }), + })).toMatchObject({ type: 'bedrock', region: 'eu-west-1', model: 'explicit-model' }) + + expect(await resolveCodexConnection({ + ...common, + authStore: createMemoryAuthStore({ codex_bedrock: { kind: 'aws-profile', active: false } }), + hasLegacyOverride: true, + })).toEqual({ type: 'chatgpt' }) + + expect(await resolveCodexConnection({ + ...common, + authStore: createMemoryAuthStore({ codex_bedrock: { + kind: 'aws-profile', active: true, region: 'us-east-1', model: 'saved-model', + } }), + })).toMatchObject({ type: 'bedrock', region: 'us-east-1', model: 'saved-model' }) + + expect(await resolveCodexConnection({ + ...common, authStore: createMemoryAuthStore(), hasLegacyOverride: true, + })).toEqual({ type: 'custom-responses' }) + + expect(await resolveCodexConnection({ + ...common, authStore: createMemoryAuthStore(), + })).toMatchObject({ type: 'bedrock', region: 'us-west-2', model: 'config-model' }) + expect(await resolveCodexConnection({ + ...common, + authStore: createMemoryAuthStore({ + codex_bedrock: { kind: 'aws-profile', profile: 'saved-but-not-selected', region: 'us-east-1' }, + }), + })).toMatchObject({ + type: 'bedrock', + profile: 'saved-but-not-selected', + region: 'us-east-1', + model: 'config-model', + }) + + expect(await resolveCodexConnection({ + selectedModelId: 'gpt-5.6-sol', codexHome: path.join(codexHome, 'missing'), env: {}, + authStore: createMemoryAuthStore(), + })).toEqual({ type: 'chatgpt' }) + }) + + test('merges unselected saved Bedrock overrides over Codex TOML', async () => { + const codexHome = await tempDirectory() + await fs.writeFile(path.join(codexHome, 'config.toml'), ` +model_provider = "amazon-bedrock" +model = "toml-model" +[model_providers.amazon-bedrock] +base_url = "https://toml.example.test/openai/v1" +[model_providers.amazon-bedrock.aws] +profile = "toml-profile" +region = "us-west-2" +`) + const result = await resolveCodexConnection({ + authStore: createMemoryAuthStore({ + codex_bedrock: { + kind: 'aws-profile', + profile: 'saved-profile', + region: 'eu-central-1', + model: 'saved-model', + baseUrl: 'https://saved.example.test/openai/v1', + }, + }), + selectedModelId: 'selected-model', + codexHome, + }) + + expect(result).toEqual({ + type: 'bedrock', + profile: 'saved-profile', + region: 'eu-central-1', + model: 'saved-model', + baseURL: 'https://saved.example.test/openai/v1', + endpointURL: 'https://saved.example.test/openai/v1/responses', + }) + }) + + test('fails closed on malformed stored Bedrock auth instead of using valid Codex TOML', async () => { + const directory = await tempDirectory() + const codexHome = path.join(directory, 'codex') + const authPath = path.join(directory, 'auth.json') + await fs.mkdir(codexHome) + await fs.writeFile(path.join(codexHome, 'config.toml'), ` +model_provider = "amazon-bedrock" +[model_providers.amazon-bedrock.aws] +region = "us-east-1" +`) + await fs.writeFile(authPath, JSON.stringify({ + codex_bedrock: { kind: 'aws-profile', active: 'true', region: 'us-west-2' }, + })) + + await expect(resolveCodexConnection({ + authStore: createFileAuthStore({ filePath: authPath }), + selectedModelId: 'gpt-5.6-sol', + codexHome, + })).rejects.toThrow('Invalid auth entry for provider: codex_bedrock') + }) + + test('resolves regional endpoint and prefixes only unprefixed selected models', async () => { + for (const selectedModelId of ['gpt-5.6-sol', 'openai.gpt-5.6-sol']) { + const result = await resolveCodexConnection({ + explicitConnection: { type: 'bedrock', region: 'us-east-1' }, + authStore: createMemoryAuthStore(), + selectedModelId, + env: {}, + }) + expect(result).toMatchObject({ + type: 'bedrock', + model: 'openai.gpt-5.6-sol', + baseURL: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1', + endpointURL: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses', + }) + } + }) + + test('uses the injected AWS region chain with the selected profile when region is omitted', async () => { + const regionProvider = mock(async () => 'ap-southeast-2') + const result = await resolveCodexConnection({ + explicitConnection: { type: 'bedrock', profile: 'work' }, + authStore: createMemoryAuthStore(), + selectedModelId: 'gpt-5.6-sol', + regionProvider, + }) + + expect(regionProvider).toHaveBeenCalledWith('work') + expect(result).toMatchObject({ + type: 'bedrock', + region: 'ap-southeast-2', + baseURL: 'https://bedrock-mantle.ap-southeast-2.api.aws/openai/v1', + }) + }) + + test('fails closed for unsupported, incomplete, malformed, and unsafe Bedrock config', async () => { + const codexHome = await tempDirectory() + await fs.writeFile(path.join(codexHome, 'config.toml'), 'model_provider = "amazon-bedrock-runtime"\n') + await expect(resolveCodexConnection({ + authStore: createMemoryAuthStore(), selectedModelId: 'model', codexHome, env: {}, + })).rejects.toThrow('not supported') + + await expect(resolveCodexConnection({ + explicitConnection: { type: 'bedrock' }, authStore: createMemoryAuthStore(), + selectedModelId: 'model', env: {}, homeDirectory: codexHome, + regionProvider: async () => { throw new Error('Region is missing') }, + })).rejects.toThrow('region could not be resolved') + + await expect(resolveCodexConnection({ + explicitConnection: { type: 'bedrock', region: 'us-east-1', baseURL: 'http://remote.test/?secret=x' }, + authStore: createMemoryAuthStore(), selectedModelId: 'model', env: {}, + })).rejects.toThrow('query string or fragment') + }) +}) diff --git a/agents/codelayer/test/providers.test.ts b/agents/codelayer/test/providers.test.ts index 345d4a7..0ba0c3d 100644 --- a/agents/codelayer/test/providers.test.ts +++ b/agents/codelayer/test/providers.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' import type { LanguageModelV3 } from '@ai-sdk/provider' +import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' import { buildProviderOptions } from '../src/agent' import { captureResponseUsage, @@ -327,6 +328,51 @@ describe('readCodexResponsesOverride', () => { }) describe('createCustomCodexResponsesModel', () => { + test('uses dynamic Bedrock auth, the wire model, and retries auth failure once', async () => { + const requests: Array<{ headers: Headers; body: Record }> = [] + const tokens = ['expired-bedrock-token', 'fresh-bedrock-token'] + let invalidations = 0 + const model = createCustomCodexResponsesModel({ + override: { + baseURL: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1', + endpointURL: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses', + wireModelId: 'openai.gpt-5.6-sol', + auth: { + type: 'bedrock', + auth: { + async getToken() { return tokens.shift()! }, + invalidate() { invalidations++ }, + }, + }, + }, + selectedModelId: 'gpt-5.6-sol', + fetch: mock(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push({ + headers: request.headers, + body: JSON.parse(await request.text()), + }) + return requests.length === 1 + ? new Response('', { status: 401 }) + : new Response(JSON.stringify({ error: { message: 'captured' } }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + }), + }) as LanguageModelV3 + + await expect(model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], + })).rejects.toThrow() + expect(requests).toHaveLength(2) + expect(requests.map((request) => request.headers.get('authorization'))).toEqual([ + 'Bearer expired-bedrock-token', + 'Bearer fresh-bedrock-token', + ]) + expect(requests[1]!.body.model).toBe('openai.gpt-5.6-sol') + expect(invalidations).toBe(1) + }) + test('uses bearer auth and the selected model by default', async () => { const { model, request } = await captureGenerateRequest() const headers = new Headers(request.init?.headers) @@ -482,11 +528,21 @@ describe('createCustomCodexResponsesModel', () => { }) describe('custom Codex Responses runtime request', () => { + test('selects Bedrock custom Responses before ChatGPT transport resolution', async () => { + const model = await resolveModel('codex', 'gpt-5.6-sol', { + codexConnection: { type: 'bedrock', region: 'us-east-1' }, + authStore: createMemoryAuthStore(), + }) as LanguageModelV3 + expect(model.provider).toBe('custom-openai-responses') + expect(model.modelId).toBe('gpt-5.6-sol') + }) + test('reports invalid setup through the host diagnostics sink', async () => { process.env.CODELAYER_CODEX_MODEL = 'azure-coding-deployment' const records: Array<{ event: string; metadata: Record }> = [] await expect(resolveModel('codex', 'gpt-5.6-sol', { + authStore: createMemoryAuthStore(), codexDiagnostics: { annotations: { sessionId: 'test-session' }, onEvent: (record) => records.push(record), @@ -514,7 +570,9 @@ describe('custom Codex Responses runtime request', () => { }) }) as typeof globalThis.fetch) - const model = await resolveModel('codex', 'gpt-5.6-sol') as LanguageModelV3 + const model = await resolveModel('codex', 'gpt-5.6-sol', { + authStore: createMemoryAuthStore(), + }) as LanguageModelV3 const providerOptions = buildProviderOptions(model, { codex: { reasoningEffort: 'high', diff --git a/bun.lock b/bun.lock index 0d6a3e4..dc0b155 100644 --- a/bun.lock +++ b/bun.lock @@ -29,14 +29,20 @@ "dependencies": { "@ai-sdk/anthropic": "catalog:", "@ai-sdk/openai": "catalog:", + "@aws-sdk/credential-providers": "catalog:", + "@aws/bedrock-token-generator": "catalog:", "@humanlayer/agentlayer-core": "workspace:*", "@humanlayer/agentlayer-filesystem": "workspace:*", "@humanlayer/agentlayer-provider-auth": "workspace:*", "@humanlayer/agentlayer-provider-github-copilot": "workspace:*", "@humanlayer/agentlayer-provider-openai-codex": "workspace:*", + "@smithy/config-resolver": "catalog:", + "@smithy/node-config-provider": "catalog:", + "@smithy/types": "catalog:", "ai": "catalog:", "chalk": "^5.6.2", "commander": "^14.0.3", + "smol-toml": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -375,13 +381,19 @@ "@ai-sdk/openai": "3.0.96", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", + "@aws-sdk/credential-providers": "3.1126.0", + "@aws/bedrock-token-generator": "1.1.0", "@pulumi/aws": "7.26.0", "@pulumi/command": "1.2.1", "@pulumi/pulumi": "3.231.0", + "@smithy/config-resolver": "4.7.2", + "@smithy/node-config-provider": "4.6.2", + "@smithy/types": "4.18.0", "ai": "6.0.199", "commander": "14.0.3", "effect": "4.0.0-beta.107", "minimatch": "10.1.1", + "smol-toml": "1.8.0", "typescript": "5.9.3", "zod": "4.5.4", }, @@ -434,6 +446,44 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.69", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-vpsh9VWmQVC/nsdzf72F2yUMBOFT1aq+hoLseYV03zjVXVHkjqSNJskFRKPFxc3MRFrHNbSSmOwsbYE15u9ecw=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.82", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1126.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-cognito-identity": "^3.972.69", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-OWu4fqH48e6yUceFs1r0fgC88gpuD/MaFggKoRXXTrjT2VicQQSO9tqlCWd2qmQNAXbwfV8/HGeRm6l9P4FSRA=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "tslib": "^2.6.2" } }, "sha512-NeLdr/PaZVJhvtkUc+rKbn/i8hdYQL53ZfTZ43vd5YUsj53r2ZTPxac0n6+fZR0it04y7Z7aLDlG3jnkY0LmrQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/bedrock-token-generator": ["@aws/bedrock-token-generator@1.1.0", "", { "dependencies": { "@aws-sdk/credential-providers": "^3.525.0", "@aws-sdk/util-format-url": ">=3.525.0", "@smithy/config-resolver": "^4.1.4", "@smithy/hash-node": ">=2.1.3", "@smithy/invalid-dependency": "^4.0.4", "@smithy/node-config-provider": "^4.1.3", "@smithy/protocol-http": ">=3.2.1", "@smithy/signature-v4": ">=2.1.3", "@smithy/types": ">=2.11.0" } }, "sha512-i+DkWnfdA4j4sffy9dI4k3OGoOWqN8CTGdtO4IZ3c0kpKYFr6KyqzqLQmoRNrF3ACFcWj6u+J6cbBQ97j9wx5w=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -898,6 +948,28 @@ "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Y1XfSefHIOub9762qm3ShafdlEE/Va8h3kLUeMq765fNeWeNLcOP2YUPr86H1SlyGwZTOqQ67RlBZPZ3k9Djgg=="], + + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-OcD8fGClTkP0BWHVEAgUp1RZyCw8cKfqTPQ+DgrSF5jvR8zKkw2Aud79L4G/1Fu3QKLcsHExxRIPQCcKx7+xkg=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-VONOgtCxIXtwXrLVZPUdxOELYpkFzNizkpbQE5CrJ/OEh12Osx+LVXsLEAF+JcvVqPODmUoOUaovlOjCQHTOow=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.6.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-zMrXu/O5tPa7GLtra8L4wFG6DACcXT9QV4Ay+WEAjUhXm1dVq7c/q9Qv9gkJZNLY8hmQKg08778kDcxpKNMqOA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.6.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Asd04MaxODN6FNY8EPTeCAM4kPNi3jDUAjZU0Y4F9rHvpLUrrUo7KLcxFgSthywFr6dZfIyDLIJda6jxmVTk5w=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + "@socketsecurity/bun-security-scanner": ["@socketsecurity/bun-security-scanner@1.1.2", "", {}, "sha512-TdsAg6SMolubyZ6HfIjLWlANfHvhV6i7pdWof4OQ33zPEwXJm2ilA755levHMR618MKq22+06Ag8efiVKowxqA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -1212,6 +1284,8 @@ "bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="], @@ -2140,7 +2214,7 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -2232,6 +2306,8 @@ "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tty-browserify": ["tty-browserify@0.0.1", "", {}, "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw=="], "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], @@ -2470,6 +2546,8 @@ "just-bash/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "just-bash/smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "lmdb/msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="], diff --git a/package.json b/package.json index 2c7100e..f97984e 100644 --- a/package.json +++ b/package.json @@ -41,18 +41,24 @@ "agents/*", "examples/*" ], - "catalog": { + "catalog": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.96", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", + "@aws/bedrock-token-generator": "1.1.0", + "@aws-sdk/credential-providers": "3.1126.0", + "@smithy/types": "4.18.0", + "@smithy/config-resolver": "4.7.2", + "@smithy/node-config-provider": "4.6.2", "@pulumi/aws": "7.26.0", "@pulumi/command": "1.2.1", "@pulumi/pulumi": "3.231.0", "ai": "6.0.199", "commander": "14.0.3", "effect": "4.0.0-beta.107", - "minimatch": "10.1.1", + "minimatch": "10.1.1", + "smol-toml": "1.8.0", "typescript": "5.9.3", "zod": "4.5.4" }, diff --git a/packages/agentlayer-provider-auth/src/auth.ts b/packages/agentlayer-provider-auth/src/auth.ts index 1b981ac..794be71 100644 --- a/packages/agentlayer-provider-auth/src/auth.ts +++ b/packages/agentlayer-provider-auth/src/auth.ts @@ -20,7 +20,16 @@ export interface ApiAuthInfo { metadata?: Record } -export type AuthInfo = OAuthAuthInfo | ApiAuthInfo +export interface AwsProfileAuthInfo { + kind: 'aws-profile' + active?: boolean + profile?: string + region?: string + model?: string + baseUrl?: string +} + +export type AuthInfo = OAuthAuthInfo | ApiAuthInfo | AwsProfileAuthInfo export type AuthKind = AuthInfo['kind'] export type CanonicalAuthProviderId = 'codex' | 'copilot' | 'copilot-enterprise' @@ -54,6 +63,13 @@ export interface FileAuthStoreOptions { enableOpenCodeFallback?: boolean } +export class InvalidAuthEntryError extends Error { + constructor(providerId: string) { + super(`Invalid auth entry for provider: ${providerId}`) + this.name = 'InvalidAuthEntryError' + } +} + interface ResolvedFileAuthStoreOptions { filePath: string agentSdkAuthFilePath: string @@ -91,12 +107,12 @@ export function createFileAuthStore(options: FileAuthStoreOptions = {}): AuthSto return { async get(providerId) { const key = normalizeProviderId(providerId) - const auth = (await readAuthFile(resolved.filePath))[key] + const auth = (await readAuthFile(resolved.filePath, key))[key] if (auth) return cloneAuth(auth) if (!resolved.enableOpenCodeFallback) return undefined - const fallbackAuth = (await readAuthFile(resolved.openCodeAuthFilePath))[key] + const fallbackAuth = (await readAuthFile(resolved.openCodeAuthFilePath, key))[key] if (!fallbackAuth) return undefined const allAuth = await readAuthFile(resolved.filePath) @@ -224,7 +240,7 @@ function getXdgDataHome(): string { return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share') } -async function readAuthFile(filePath: string): Promise> { +async function readAuthFile(filePath: string, requestedProviderId?: string): Promise> { let text: string try { text = await fs.readFile(filePath, 'utf8') @@ -238,9 +254,17 @@ async function readAuthFile(filePath: string): Promise> const allAuth: Record = {} for (const [providerId, value] of Object.entries(parsed)) { + const normalizedProviderId = normalizeProviderId(providerId) const auth = decodeAuthInfo(value) + if ( + requestedProviderId === 'codex_bedrock' && + normalizedProviderId === requestedProviderId && + auth?.kind !== 'aws-profile' + ) { + throw new InvalidAuthEntryError(requestedProviderId) + } if (!auth) continue - allAuth[normalizeProviderId(providerId)] = auth + allAuth[normalizedProviderId] = auth } return allAuth } @@ -259,12 +283,28 @@ function decodeAuthInfo(value: unknown): AuthInfo | undefined { if (value.kind === 'oauth') return decodeOAuthAuthInfo(value) if (value.kind === 'api') return decodeApiAuthInfo(value) + if (value.kind === 'aws-profile') return decodeAwsProfileAuthInfo(value) if (value.type === 'oauth') return decodeOpenCodeOAuthAuthInfo(value) if (value.type === 'api') return decodeOpenCodeApiAuthInfo(value) return undefined } +function decodeAwsProfileAuthInfo(value: Record): AwsProfileAuthInfo | undefined { + if (value.active !== undefined && typeof value.active !== 'boolean') return undefined + for (const key of ['profile', 'region', 'model', 'baseUrl']) { + if (value[key] !== undefined && typeof value[key] !== 'string') return undefined + } + return { + kind: 'aws-profile', + ...(typeof value.active === 'boolean' ? { active: value.active } : {}), + ...optionalString('profile', value.profile), + ...optionalString('region', value.region), + ...optionalString('model', value.model), + ...optionalString('baseUrl', value.baseUrl), + } +} + function decodeOAuthAuthInfo(value: Record): OAuthAuthInfo | undefined { const accessToken = typeof value.accessToken === 'string' ? value.accessToken : value.access if (typeof accessToken !== 'string') return undefined diff --git a/packages/agentlayer-provider-auth/test/auth-disk.test.ts b/packages/agentlayer-provider-auth/test/auth-disk.test.ts index 109b996..85f85ec 100644 --- a/packages/agentlayer-provider-auth/test/auth-disk.test.ts +++ b/packages/agentlayer-provider-auth/test/auth-disk.test.ts @@ -80,6 +80,93 @@ describe('createFileAuthStore', () => { expect(stat.mode & 0o777).toBe(0o600) }) + test('round-trips codex_bedrock without changing codex or unrelated providers', async () => { + const dir = await makeTempDir() + const filePath = path.join(dir, 'auth.json') + await fs.writeFile( + filePath, + JSON.stringify({ + codex: { kind: 'oauth', accessToken: 'access', refreshToken: 'refresh', expiresAt: 123 }, + other: { kind: 'api', apiKey: 'other-key', metadata: { owner: 'test' } }, + }), + ) + const store = createFileAuthStore({ filePath }) + + await store.set('codex_bedrock', { + kind: 'aws-profile', + active: true, + profile: 'work', + region: 'us-east-1', + model: 'openai.gpt-5.6-sol', + baseUrl: 'https://bedrock.example.test/openai/v1', + }) + + expect(await store.get('codex_bedrock')).toEqual({ + kind: 'aws-profile', + active: true, + profile: 'work', + region: 'us-east-1', + model: 'openai.gpt-5.6-sol', + baseUrl: 'https://bedrock.example.test/openai/v1', + }) + const document = JSON.parse(await fs.readFile(filePath, 'utf8')) + expect(document.codex).toEqual({ + kind: 'oauth', + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: 123, + }) + expect(document.other).toEqual({ kind: 'api', apiKey: 'other-key', metadata: { owner: 'test' } }) + }) + + test('fails closed when codex_bedrock is malformed without affecting unrelated reads or the file', async () => { + const dir = await makeTempDir() + const filePath = path.join(dir, 'auth.json') + const original = JSON.stringify({ + codex: { kind: 'oauth', accessToken: 'access' }, + codex_bedrock: { kind: 'aws-profile', active: 'yes', region: 'us-east-1' }, + }) + await fs.writeFile(filePath, original) + + const store = createFileAuthStore({ filePath }) + await expect(store.get('codex_bedrock')).rejects.toThrow('Invalid auth entry for provider: codex_bedrock') + expect(await store.get('codex')).toEqual({ kind: 'oauth', accessToken: 'access' }) + expect(await store.getAll()).toEqual({ codex: { kind: 'oauth', accessToken: 'access' } }) + expect(await fs.readFile(filePath, 'utf8')).toBe(original) + }) + + test('rejects a decodable non-AWS auth variant stored as codex_bedrock', async () => { + const dir = await makeTempDir() + const filePath = path.join(dir, 'auth.json') + await fs.writeFile( + filePath, + JSON.stringify({ + codex_bedrock: { kind: 'api', apiKey: 'not-a-bedrock-profile' }, + }), + ) + + await expect(createFileAuthStore({ filePath }).get('codex_bedrock')).rejects.toThrow( + 'Invalid auth entry for provider: codex_bedrock', + ) + }) + + test('loads a manually configured aws-profile without an active selection marker', async () => { + const dir = await makeTempDir() + const filePath = path.join(dir, 'auth.json') + await fs.writeFile( + filePath, + JSON.stringify({ + codex_bedrock: { kind: 'aws-profile', profile: 'work', region: 'us-east-1' }, + }), + ) + + expect(await createFileAuthStore({ filePath }).get('codex_bedrock')).toEqual({ + kind: 'aws-profile', + profile: 'work', + region: 'us-east-1', + }) + }) + test('supports convenience read, write, remove, and list helpers', async () => { const dir = await makeTempDir() const filePath = path.join(dir, 'auth.json') diff --git a/packages/agentlayer-provider-openai-codex/src/providers/sse-vendor-provider/index.ts b/packages/agentlayer-provider-openai-codex/src/providers/sse-vendor-provider/index.ts index 9c7266d..f498ed5 100644 --- a/packages/agentlayer-provider-openai-codex/src/providers/sse-vendor-provider/index.ts +++ b/packages/agentlayer-provider-openai-codex/src/providers/sse-vendor-provider/index.ts @@ -249,6 +249,9 @@ export function createCodexSseVendorProvider(options: CodexSseVendorProviderOpti const resolveAuth = async () => { const auth = await resolveCodexAuth(authStore, providerId, fetchFn, now) + if (auth.kind === 'aws-profile') { + throw new Error('AWS profile auth cannot be used with the ChatGPT Codex provider') + } const token = auth.kind === 'api' ? auth.apiKey : auth.accessToken const accountId = auth.kind === 'oauth' ? (auth.accountId ?? undefined) : undefined return { token, accountId } diff --git a/packages/agentlayer-provider-openai-codex/src/providers/websockets-vendor-provider/index.ts b/packages/agentlayer-provider-openai-codex/src/providers/websockets-vendor-provider/index.ts index 62da0c1..adfcc7c 100644 --- a/packages/agentlayer-provider-openai-codex/src/providers/websockets-vendor-provider/index.ts +++ b/packages/agentlayer-provider-openai-codex/src/providers/websockets-vendor-provider/index.ts @@ -249,6 +249,9 @@ export function createCodexEffectProvider(options: CodexEffectProviderOptions = const resolveAuth = async () => { const auth = await resolveCodexAuth(authStore, providerId, fetchFn, now) + if (auth.kind === 'aws-profile') { + throw new Error('AWS profile auth cannot be used with the ChatGPT Codex provider') + } const token = auth.kind === 'api' ? auth.apiKey : auth.accessToken const accountId = auth.kind === 'oauth' ? (auth.accountId ?? undefined) : undefined return { token, accountId } diff --git a/packages/agentlayer-provider-openai-codex/test/ws-connection-limit.manual-test.ts b/packages/agentlayer-provider-openai-codex/test/ws-connection-limit.manual-test.ts index d31def2..73b40f7 100644 --- a/packages/agentlayer-provider-openai-codex/test/ws-connection-limit.manual-test.ts +++ b/packages/agentlayer-provider-openai-codex/test/ws-connection-limit.manual-test.ts @@ -34,6 +34,7 @@ interface ConnectionResult { async function resolveHeaders(): Promise> { const authStore = await ensureFileAuthStore() const auth = await resolveCodexAuth(authStore, 'codex', globalThis.fetch, Date.now) + if (auth.kind === 'aws-profile') throw new Error('AWS profile auth cannot be used with the ChatGPT Codex provider') const token = auth.kind === 'api' ? auth.apiKey : auth.accessToken const headers: Record = { authorization: `Bearer ${token}`,