Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36b9c1e
feat(vertex): accept pasted GCP credential JSON in the API-key slot (…
GSL-R Oct 9, 2026
369f7f2
refactor(vertex): pass the marker account per request; review fixes (…
GSL-R Oct 9, 2026
12a0adc
fix(vertex): preserve the marker through routing; harden paste handli…
GSL-R Oct 9, 2026
857ed8c
docs: attest review readiness checklist (4/4 ticked)
GSL-R Oct 10, 2026
8c025fd
docs: attest readiness 4/4; trigger gate re-evaluation
GSL-R Oct 10, 2026
0f0ef85
fix(gcp-adc): suppress the GOOGLE_CLOUD_API_KEY fallback for named ma…
GSL-R Oct 10, 2026
f87521b
test(vertex): cover trailing credential paste validation
GSL-R Oct 10, 2026
dd99887
Merge remote-tracking branch 'refs/remotes/prana-upstream/dev' into f…
GSL-R Oct 10, 2026
c3defe8
fix(gcp-adc): validate markers and clean up credential lifecycle
GSL-R Oct 10, 2026
9dd9131
Merge branch 'dev' of https://github.com/lidge-jun/opencodex into fea…
GSL-R Oct 11, 2026
2905bf5
fix(gcp-adc): clean credentials after provider batch and overwrite co…
GSL-R Oct 11, 2026
fca8053
Merge remote-tracking branch 'upstream/dev' into feat/vertex-sa-json-…
GSL-R Oct 11, 2026
950260f
fix(management): include durable provider markers in DELETE cleanup
GSL-R Oct 11, 2026
da9d2fc
fix(gcp-adc): validate required fields in parseGcpCredentialJson befo…
GSL-R Oct 11, 2026
f919a6a
style(management): keep GCP cleanup within file-size gate
GSL-R Oct 11, 2026
df1a6b0
merge dev: preserve Vertex and live topology config contracts
GSL-R Oct 11, 2026
1f43977
fix(vertex): reject credential JSON before generic provider persistence
lidge-jun Oct 11, 2026
93825e8
fix(vertex): preserve GCP keychain payloads across key migrations
lidge-jun Oct 11, 2026
8d36e31
fix(vertex): serialize credential writes with marker commits and cleanup
lidge-jun Oct 11, 2026
9941f54
merge dev: preserve both test layout inventories
GSL-R Oct 12, 2026
686b6ba
fix(cli): clean removed GCP credentials after provider overwrite
GSL-R Oct 12, 2026
c81a6f7
fix(cli): collect durable GCP markers under overwrite lock
GSL-R Oct 12, 2026
dc6e85b
fix(cli): validate raw keys before registry auth ignores them
GSL-R Oct 12, 2026
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
19 changes: 19 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,25 @@ model key.

## Storing keys in the OS keychain

For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP
`service_account` or `authorized_user` credential JSON after the provider has been created.
The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before
saving: create the provider without a key, then add the JSON through its key pool. Paste the
JSON contents, not a Windows, Unix, or relative file path. Multiple
JSON objects separated by commas or whitespace are stored together as separate pool entries;
the first becomes active. Configuration contains only `gcp-sa:<account>` references, never the
credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys
separately. Storage failures leave the configuration unchanged and report a fixed error without
echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials.
Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is
saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a
warning requests manual inspection without undoing the committed configuration removal.
Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring
credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still
referenced through either marker spelling.
Credential writes and marker persistence share the cleanup transaction lock, so concurrent
removal cannot delete the stored JSON between read-back and the marker commit.

By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes).
If you would rather keep the key material out of the file, move it into the OS credential store:

Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
"explicit": {
"explicit": {"gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server",
"responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration",
"messages-request-id-endpoint.test.ts": "claude-integration",
"anthropic-revoked-token.test.ts": "adapters/anthropic",
Expand Down
18 changes: 15 additions & 3 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
import type { OcxTool } from "../types";
import { contentPartsToText, parseDataUrl } from "./image";
import { getVertexAccessToken } from "../lib/gcp-adc";
import { GCP_CREDENTIAL_MARKER_PREFIX, gcpCredentialMarkerAccount, getVertexAccessToken } from "../lib/gcp-adc";
import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
import { isVertexTruncatedTurn, vertexTruncationErrorEvent } from "./google-truncation";
Expand Down Expand Up @@ -174,7 +174,15 @@ function resolveDirectGeminiWireModelId(modelId: string, applyRenames: boolean):

/** Vertex API key: provider.apiKey if it looks real (not a sentinel), else GOOGLE_CLOUD_API_KEY env. */
function resolveVertexApiKey(optKey?: string): string | undefined {
const realKey = optKey && !optKey.startsWith("<") && optKey !== "N/A" ? optKey : undefined;
// A `gcp-sa:` marker is the keychain-backed credential reference (see gcp-adc): it is not
// API-key material, so it must not be sent as x-goog-api-key — the ADC branch below handles it.
// AND the marker means the operator named one credential explicitly: the GOOGLE_CLOUD_API_KEY
// env fallback must NOT fire (it would silently route this provider's inference through the
// ambient env key instead of the configured service account).
if (optKey && optKey.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const realKey = optKey && !optKey.startsWith("<") && optKey !== "N/A"
? optKey
: undefined;
return realKey || process.env.GOOGLE_CLOUD_API_KEY;
}

Expand Down Expand Up @@ -1197,7 +1205,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
if (locationError) throw new Error(locationError);
const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
const token = await getVertexAccessToken();
// The marker account travels per-request as an argument (never process-global state): a
// module-level "active marker" written at routing time would race across concurrent
// requests for different providers and could resolve one call with another's credential.
const markerAccount = gcpCredentialMarkerAccount(provider.apiKey);
const token = await getVertexAccessToken({ markerAccount });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
headers["Authorization"] = `Bearer ${token}`;
return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
}
Expand Down
25 changes: 23 additions & 2 deletions src/cli/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
* show <name> Show provider config details (secrets masked)
* set-default <name> Change the default provider
*/
import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate, withConfigMutationLockSync } from "../config";
import { hasOwnProvider, isValidProviderName, loadConfig, readConfigAdmissionSnapshot, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate, withConfigMutationLockSync } from "../config";
import { apiKeyTransportConfigError, modelCapabilitiesConfigError, mergeModelCapabilities } from "../config/provider-validation";
import { hasHelpFlag, printSubcommandUsage } from "./help";
import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
import { providerConfigSeed } from "../providers/derive";
import { assertAnthropicInstanceLoginConfig } from "../oauth/store-anthropic-instance";
import { dropProviderCustomModels } from "../providers/provider-id-rewrite";
import { cleanupRemovedGcpCredentials, providerGcpCredentialMarkers } from "../providers/gcp-credential-cleanup";
import { providerCredentialInputError } from "../providers/api-keys";
import type { OcxProviderConfig } from "../types";
import { findLiveProxy } from "../server/proxy-liveness";
import { syncModelsToCodex } from "../codex/sync";
Expand Down Expand Up @@ -223,6 +225,12 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
if (registryEntry) {
provConfig = providerConfigSeed(registryEntry);
if (apiKey) {
const candidateError = providerCredentialInputError({ apiKey });
if (candidateError) {
console.error(`Error: ${candidateError}`);
process.exitCode = 2;
return;
}
if ((authMode ?? registryEntry.authKind) === "forward") {
console.warn(`Warning: provider "${name}" uses ChatGPT login (forward auth); --api-key is ignored.`);
} else if ((authMode ?? registryEntry.authKind) === "oauth") {
Expand All @@ -249,6 +257,12 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
}

if (responsesPath !== undefined) provConfig.responsesPath = responsesPath;
const credentialError = providerCredentialInputError(provConfig);
if (credentialError) {
console.error(`Error: ${credentialError}`);
process.exitCode = 2;
return;
}
if (authMode !== undefined) provConfig.authMode = authMode as OcxProviderConfig["authMode"];
if (name === "anthropic2") {
if (provConfig.adapter !== "anthropic" || provConfig.authMode !== "oauth") delete provConfig.anthropicOAuthInstance;
Expand Down Expand Up @@ -328,12 +342,17 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
}
const { initializeProviderModelSelection } = await import("../providers/initial-model-selection");
let markerCollision = false;
let removedCredentials: string[] = [];
withConfigMutationLockSync(() => {
if (provConfig.anthropicOAuthInstance) {
try { assertAnthropicInstanceLoginConfig(config, name); }
catch { markerCollision = true; return; }
}
initializeProviderModelSelection(name, provConfig, existingProvider, config);
const durable = readConfigAdmissionSnapshot();
const durableProvider = durable.kind === "read" && durable.diagnostics.source === "file"
&& durable.diagnostics.error === null ? durable.diagnostics.config.providers[name] : undefined;
removedCredentials = [config.providers[name], durableProvider].flatMap(provider => provider ? providerGcpCredentialMarkers(provider) : []);
config.providers[name] = provConfig;
if (setDefault) config.defaultProvider = name;
validateAndSave(config);
Expand All @@ -344,6 +363,7 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
return;
}

cleanupRemovedGcpCredentials(removedCredentials);
let sync: LocalSyncResult | undefined;
if (wantsSync) {
try {
Expand Down Expand Up @@ -440,10 +460,11 @@ function handleRemove(args: string[]): void {
process.exit(1);
}

const removedCredentials = providerGcpCredentialMarkers(config.providers[name]!);
delete config.providers[name];
const droppedCustomModels = dropProviderCustomModels(config, name);
validateAndSave(config);

cleanupRemovedGcpCredentials(removedCredentials);

if (wantsJson) {
console.log(JSON.stringify({
Expand Down
98 changes: 89 additions & 9 deletions src/lib/gcp-adc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
* Application Default Credentials (ADC) resolution for Vertex AI.
*
* Direct WebCrypto + REST implementation (no `google-auth-library`). Sources, in priority order:
* 0. A `gcp-sa:<account>` reference (the provider's active key resolving to one) → the Service
* Account / authorized_user JSON stored in the OS credential store under that account
* (`keychain:<account>` secret, written by the key-store save path when the user pastes a
* credential JSON directly into the API-key slot). See resolveGcpCredentialMarker.
* 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT
* exchange) or `type: "authorized_user"` (refresh-token exchange).
* 2. gcloud user ADC (`CLOUDSDK_CONFIG`, else `%APPDATA%\gcloud` on Windows, else
Expand All @@ -18,6 +22,7 @@ import { Buffer } from "node:buffer";
import * as os from "node:os";
import * as path from "node:path";
import { readFileSync, existsSync, statSync } from "node:fs";
import { GCP_CREDENTIAL_MARKER_PREFIX, providerKeychainEntry } from "../providers/api-key-resolve";
import {
captureConfigGeneration,
sweepExpiredOnWrite,
Expand All @@ -36,6 +41,50 @@ const TOKEN_TIMEOUT_MS = 15_000;
const TOKEN_ATTEMPTS = 3;
const TOKEN_RETRY_BASE_MS = 300;

/**
* Reference prefix stored in `provider.apiKey` (and `apiKeyPool` entries) when the user pastes a
* GCP credential JSON directly into an API-key slot. The secret JSON lives in the OS credential
* store under the same service the `keychain:` references use; only the marker is persisted to
* `config.json`. The leaf reference prefix also identifies already-stored material for migration.
*/
export { GCP_CREDENTIAL_MARKER_PREFIX } from "../providers/api-key-resolve";

/** Extract a marker account; malformed explicit markers never opt into host ADC. */
export function gcpCredentialMarkerAccount(value: string | undefined): string | undefined {
if (typeof value !== "string" || !value.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return undefined;
const account = value.slice(GCP_CREDENTIAL_MARKER_PREFIX.length);
if (!account.trim()) throw new Error("GCP credential marker requires a non-empty account");
return account;
}

/**
* Whether a pasted API-key value is in fact a GCP credential JSON. Used by the save path to
* divert JSON material out of the literal-key flow before it reaches `config.json`. Deliberately
* strict: only a `type` field with a known ADC credential type counts, so a user's literal API
* key that happens to start with `{` is never eaten.
*/
export function parseGcpCredentialJson(value: string): AdcFileCredentials | undefined {
const trimmed = (value ?? "").trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (parsed?.type === "service_account") {
if (typeof parsed.client_email !== "string" || !parsed.client_email.trim()) return undefined;
if (typeof parsed.private_key !== "string" || !parsed.private_key.trim()) return undefined;
return parsed as unknown as AdcFileCredentials;
}
if (parsed?.type === "authorized_user") {
if (typeof parsed.client_id !== "string" || !parsed.client_id.trim()) return undefined;
if (typeof parsed.client_secret !== "string" || !parsed.client_secret.trim()) return undefined;
if (typeof parsed.refresh_token !== "string" || !parsed.refresh_token.trim()) return undefined;
return parsed as unknown as AdcFileCredentials;
}
return undefined;
} catch {
return undefined;
}
}

interface CachedToken {
token: string;
expiresAtMs: number;
Expand Down Expand Up @@ -81,7 +130,10 @@ export function sweepExpiredGcpAdcTokens(now = Date.now()): number {

export function reconcileGcpAdcTokens(context: GenerationContext): number {
if (context.generation <= lastReconciledGeneration) return 0;
const currentSource = currentAdcSourceKey();
// The periodic sweep registration has no request context, so no marker is active here: markers
// travel per-request via getVertexAccessToken(options.markerAccount), and a marker-token cache
// entry whose account no longer resolves is simply pruned by this sweep when it expires.
const currentSource = currentAdcSourceKey(undefined);
let removed = 0;
for (const source of tokenCache.keys()) {
if (source === currentSource) continue;
Expand Down Expand Up @@ -131,7 +183,29 @@ function readJsonFile<T>(filePath: string): T | undefined {
return JSON.parse(readFileSync(filePath, "utf8")) as T;
}

function loadAdcCredentials(): { source: string; creds: AdcFileCredentials } | undefined {
/**
* Read the credential JSON for a `gcp-sa:<account>` marker from the OS credential store. Uses the
* same service name and the same injectable entry factory as the `keychain:` API-key references
* (api-key-resolve.providerKeychainEntry), so one probe surface and one test seam cover both.
* Mirrors `readKeychain`'s fail-closed policy: an unreadable entry yields no credential (the
* caller surfaces the failure instead of silently falling through to the next ADC source).
*/
function readMarkerCredential(account: string): AdcFileCredentials | undefined {
try {
const raw = providerKeychainEntry(account).getPassword();
if (typeof raw !== "string" || !raw.trim()) return undefined;
return parseGcpCredentialJson(raw);
} catch {
return undefined;
}
}

function loadAdcCredentials(markerAccount: string | undefined): { source: string; creds: AdcFileCredentials } | undefined {
if (markerAccount) {
const creds = readMarkerCredential(markerAccount);
if (!creds) throw new Error(`gcp-sa:${markerAccount} marker is set but its credential could not be read from the OS credential store`);
return { source: `marker:${markerAccount}`, creds };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
if (gacPath) {
const creds = readJsonFile<AdcFileCredentials>(gacPath);
Expand All @@ -148,9 +222,10 @@ function loadAdcCredentials(): { source: string; creds: AdcFileCredentials } | u
* The cache key for the source the NEXT resolve would use, computed cheaply (no network). Lets the
* cache return a token only when it still matches the active credential source, so an in-process
* change to GOOGLE_APPLICATION_CREDENTIALS (or the user ADC file) does not keep serving a stale
* token from a different source. Falls back to "metadata" when no file/env ADC is present.
* token from a different source. Falls back to "metadata" when no marker/file/env ADC is present.
*/
function currentAdcSourceKey(): string {
function currentAdcSourceKey(markerAccount: string | undefined): string {
if (markerAccount) return `marker:${markerAccount}`;
const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
if (gacPath) return fileSourceTag("gac", gacPath);
const userPath = userAdcPath();
Expand Down Expand Up @@ -279,8 +354,8 @@ async function fetchMetadataToken(signal: AbortSignal | undefined, fetchImpl: Fe
}
}

async function resolveAccessTokenUncached(signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<{ source: string; token: TokenResponse }> {
const adc = loadAdcCredentials();
async function resolveAccessTokenUncached(markerAccount: string | undefined, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<{ source: string; token: TokenResponse }> {
const adc = loadAdcCredentials(markerAccount);
if (adc) {
const token = adc.creds.type === "service_account"
? await exchangeJwtForToken(adc.creds, signal, fetchImpl)
Expand All @@ -295,14 +370,19 @@ async function resolveAccessTokenUncached(signal: AbortSignal | undefined, fetch
}

/** Returns a Bearer access token for the `Authorization` header on Vertex AI calls (cached + refreshed). */
export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl }): Promise<string> {
export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl; markerAccount?: string }): Promise<string> {
// The marker travels as an argument, never as process-global state: a module-level "active
// marker" written by the router would race across concurrent requests for different providers
// and could resolve one provider's Vertex call with another provider's service account. The
// adapter passes `gcpCredentialMarkerAccount(provider.apiKey)` per request instead.
const markerAccount = options?.markerAccount;
const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
const skew = getRefreshSkewMs();
const now = Date.now();

// Only serve a cached token that matches the source the next resolve would actually use; prune
// expired or now-stale (different-source) entries so a credential-source change is honored.
const expectedSource = currentAdcSourceKey();
const expectedSource = currentAdcSourceKey(markerAccount);
const writerGeneration = captureConfigGeneration();
for (const [source, cached] of tokenCache) {
if (source === expectedSource && cached.expiresAtMs - skew > now) return cached.token;
Expand All @@ -317,7 +397,7 @@ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fet

const promise = (async () => {
try {
const { source, token } = await resolveAccessTokenUncached(options?.signal, fetchImpl);
const { source, token } = await resolveAccessTokenUncached(markerAccount, options?.signal, fetchImpl);
const expiresAtMs = Date.now() + Math.max(0, token.expires_in * 1000);
if (writerGeneration >= lastReconciledGeneration || liveSources.has(source)) {
tokenCache.set(source, { token: token.access_token, expiresAtMs });
Expand Down
Loading
Loading