refactor(auth): isolate external oauth overlays

This commit is contained in:
Peter Steinberger
2026-04-06 13:29:46 +01:00
parent 49e3ecfe5e
commit 7e0e2f81e5
12 changed files with 588 additions and 59 deletions

View File

@@ -615,7 +615,8 @@ Provider plugins now have two layers:
- runtime hooks: `normalizeModelId`, `normalizeTransport`,
`normalizeConfig`,
`applyNativeStreamingUsageCompat`, `resolveConfigApiKey`,
`resolveSyntheticAuth`, `shouldDeferSyntheticProfileAuth`,
`resolveSyntheticAuth`, `resolveExternalOAuthProfiles`,
`shouldDeferSyntheticProfileAuth`,
`resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`,
`contributeResolvedModelCompat`, `capabilities`,
`normalizeToolSchemas`, `inspectToolSchemas`,
@@ -648,52 +649,53 @@ client-id/client-secret setup vars.
For model/provider plugins, OpenClaw calls hooks in this rough order.
The "When to use" column is the quick decision guide.
| # | Hook | What it does | When to use |
| --- | --------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults |
| 2 | `applyConfigDefaults` | Apply provider-owned global config defaults during config materialization | Defaults depend on auth mode, env, or provider model-family semantics |
| -- | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ |
| 3 | `normalizeModelId` | Normalize legacy or preview model-id aliases before lookup | Provider owns alias cleanup before canonical model resolution |
| 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family |
| 5 | `normalizeConfig` | Normalize `models.providers.<id>` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries |
| 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes |
| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here |
| 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker |
| 9 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence |
| 10 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids |
| 11 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids |
| 12 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport |
| 13 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider |
| 14 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks |
| 15 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup |
| 16 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules |
| 17 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields |
| 18 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
| 19 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper |
| 20 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport |
| 21 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity |
| 22 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy |
| 23 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape |
| 24 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers |
| 25 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
| 26 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss |
| 27 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
| 28 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
| 29 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
| 30 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
| 31 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
| 32 | `isBinaryThinking` | On/off reasoning toggle for binary-thinking providers | Provider exposes only binary thinking on/off |
| 33 | `supportsXHighThinking` | `xhigh` reasoning support for selected models | Provider wants `xhigh` on only a subset of models |
| 34 | `resolveDefaultThinkingLevel` | Default `/think` level for a specific model family | Provider owns default `/think` policy for a model family |
| 35 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
| 36 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
| 37 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
| 38 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
| 39 | `createEmbeddingProvider` | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin |
| 40 | `buildReplayPolicy` | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) |
| 41 | `sanitizeReplayHistory` | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers |
| 42 | `validateReplayTurns` | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation |
| 43 | `onModelSelected` | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active |
| # | Hook | What it does | When to use |
| --- | --------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults |
| 2 | `applyConfigDefaults` | Apply provider-owned global config defaults during config materialization | Defaults depend on auth mode, env, or provider model-family semantics |
| -- | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ |
| 3 | `normalizeModelId` | Normalize legacy or preview model-id aliases before lookup | Provider owns alias cleanup before canonical model resolution |
| 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family |
| 5 | `normalizeConfig` | Normalize `models.providers.<id>` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries |
| 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes |
| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here |
| 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker |
| 9 | `resolveExternalOAuthProfiles` | Overlay external OAuth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external OAuth credentials without persisting copied refresh tokens |
| 10 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence |
| 11 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids |
| 12 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids |
| 13 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport |
| 14 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider |
| 15 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks |
| 16 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup |
| 17 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules |
| 18 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields |
| 19 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
| 20 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper |
| 21 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport |
| 22 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity |
| 23 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy |
| 24 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape |
| 25 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers |
| 26 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
| 27 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss |
| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
| 33 | `isBinaryThinking` | On/off reasoning toggle for binary-thinking providers | Provider exposes only binary thinking on/off |
| 34 | `supportsXHighThinking` | `xhigh` reasoning support for selected models | Provider wants `xhigh` on only a subset of models |
| 35 | `resolveDefaultThinkingLevel` | Default `/think` level for a specific model family | Provider owns default `/think` policy for a model family |
| 36 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
| 37 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
| 38 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
| 39 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
| 40 | `createEmbeddingProvider` | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin |
| 41 | `buildReplayPolicy` | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) |
| 42 | `sanitizeReplayHistory` | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers |
| 43 | `validateReplayTurns` | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation |
| 44 | `onModelSelected` | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active |
`normalizeModelId`, `normalizeTransport`, and `normalizeConfig` first check the
matched provider plugin, then fall through other hook-capable provider plugins

View File

@@ -1,4 +1,5 @@
type CodexJwtPayload = {
exp?: unknown;
iss?: unknown;
sub?: unknown;
"https://api.openai.com/profile"?: {
@@ -19,6 +20,16 @@ function normalizeNonEmptyString(value: unknown): string | undefined {
return trimmed || undefined;
}
function normalizeFutureEpochSeconds(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
return Number.parseInt(value.trim(), 10);
}
return undefined;
}
export function decodeCodexJwtPayload(accessToken: string): CodexJwtPayload | null {
const parts = accessToken.split(".");
if (parts.length !== 3) {
@@ -55,6 +66,12 @@ export function resolveCodexStableSubject(payload: CodexJwtPayload | null): stri
return sub;
}
export function resolveCodexAccessTokenExpiry(accessToken: string): number | undefined {
const payload = decodeCodexJwtPayload(accessToken);
const exp = normalizeFutureEpochSeconds(payload?.exp);
return exp ? exp * 1000 : undefined;
}
export function resolveCodexAuthIdentity(params: { accessToken: string; email?: string | null }): {
email?: string;
profileName?: string;

View File

@@ -0,0 +1,84 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
OPENAI_CODEX_DEFAULT_PROFILE_ID,
readOpenAICodexCliOAuthProfile,
} from "./openai-codex-cli-auth.js";
function buildJwt(payload: Record<string, unknown>) {
const encode = (value: Record<string, unknown>) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "none", typ: "JWT" })}.${encode(payload)}.sig`;
}
describe("readOpenAICodexCliOAuthProfile", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reads Codex CLI chatgpt auth into the default OpenAI Codex profile", () => {
const accessToken = buildJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/profile": {
email: "codex@example.com",
},
});
vi.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
},
}),
);
const parsed = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(parsed).toMatchObject({
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: {
type: "oauth",
provider: "openai-codex",
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
email: "codex@example.com",
managedBy: "codex-cli",
},
});
expect(parsed?.credential.expires).toBeGreaterThan(Date.now());
});
it("does not override a locally managed OpenAI Codex profile", () => {
vi.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: "access-token",
refresh_token: "refresh-token",
},
}),
);
const parsed = readOpenAICodexCliOAuthProfile({
store: {
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "local-access",
refresh: "local-refresh",
expires: Date.now() + 60_000,
},
},
},
});
expect(parsed).toBeNull();
});
});

View File

@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import type { AuthProfileStore, OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth";
import {
resolveCodexAccessTokenExpiry,
resolveCodexAuthIdentity,
} from "./openai-codex-auth-identity.js";
const PROVIDER_ID = "openai-codex";
const CODEX_CLI_MANAGED_BY = "codex-cli";
export const CODEX_CLI_PROFILE_ID = `${PROVIDER_ID}:codex-cli`;
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`;
type CodexCliAuthFile = {
auth_mode?: unknown;
tokens?: {
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
};
};
function trimNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function resolveCodexCliHome(env: NodeJS.ProcessEnv): string {
const configured = trimNonEmptyString(env.CODEX_HOME);
if (!configured) {
return path.join(resolveRequiredHomeDir(), ".codex");
}
if (configured === "~") {
return resolveRequiredHomeDir();
}
if (configured.startsWith("~/")) {
return path.join(resolveRequiredHomeDir(), configured.slice(2));
}
return path.resolve(configured);
}
function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null {
try {
const authPath = path.join(resolveCodexCliHome(env), "auth.json");
const raw = fs.readFileSync(authPath, "utf8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as CodexCliAuthFile) : null;
} catch {
return null;
}
}
export function readOpenAICodexCliOAuthProfile(params: {
env?: NodeJS.ProcessEnv;
store: AuthProfileStore;
}): { profileId: string; credential: OAuthCredential } | null {
const existing = params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
if (
existing?.type === "oauth" &&
existing.provider === PROVIDER_ID &&
existing.managedBy !== CODEX_CLI_MANAGED_BY
) {
return null;
}
const authFile = readCodexCliAuthFile(params.env ?? process.env);
if (!authFile || authFile.auth_mode !== "chatgpt") {
return null;
}
const access = trimNonEmptyString(authFile.tokens?.access_token);
const refresh = trimNonEmptyString(authFile.tokens?.refresh_token);
if (!access || !refresh) {
return null;
}
const accountId = trimNonEmptyString(authFile.tokens?.account_id);
const identity = resolveCodexAuthIdentity({ accessToken: access });
return {
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access,
refresh,
expires: resolveCodexAccessTokenExpiry(access) ?? 0,
...(accountId ? { accountId } : {}),
...(identity.email ? { email: identity.email } : {}),
...(identity.profileName ? { displayName: identity.profileName } : {}),
managedBy: CODEX_CLI_MANAGED_BY,
},
};
}

View File

@@ -4,7 +4,6 @@ import type {
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
type OAuthCredential,
@@ -22,6 +21,7 @@ import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js";
import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js";
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
import { CODEX_CLI_PROFILE_ID, readOpenAICodexCliOAuthProfile } from "./openai-codex-cli-auth.js";
import { buildOpenAIReplayPolicy } from "./replay-policy.js";
import {
cloneFirstTemplateModel,
@@ -303,6 +303,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
},
resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx),
buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx),
resolveExternalOAuthProfiles: (ctx) => {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env,
store: ctx.store,
});
return profile ? [{ ...profile, persistence: "runtime-only" }] : undefined;
},
supportsXHighThinking: ({ modelId }) =>
matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),

View File

@@ -5,7 +5,6 @@ export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
export const LEGACY_AUTH_FILENAME = "auth.json";
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
export const AUTH_STORE_LOCK_OPTIONS = {
retries: {

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderExternalOAuthProfile } from "../../plugins/types.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
const resolveExternalOAuthProfilesWithPluginsMock = vi.fn<
(params: unknown) => ProviderExternalOAuthProfile[]
>(() => []);
vi.mock("../../plugins/provider-runtime.js", () => ({
resolveExternalOAuthProfilesWithPlugins: (params: unknown) =>
resolveExternalOAuthProfilesWithPluginsMock(params),
}));
function createStore(profiles: AuthProfileStore["profiles"] = {}): AuthProfileStore {
return { version: 1, profiles };
}
function createCredential(overrides: Partial<OAuthCredential> = {}): OAuthCredential {
return {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: 123,
managedBy: "codex-cli",
...overrides,
};
}
describe("auth external oauth helpers", () => {
beforeEach(() => {
resolveExternalOAuthProfilesWithPluginsMock.mockReset();
});
it("overlays provider-managed runtime oauth profiles onto the store", async () => {
resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([
{
profileId: "openai-codex:default",
credential: createCredential(),
},
]);
const { overlayExternalOAuthProfiles } = await import("./external-oauth.js");
const store = overlayExternalOAuthProfiles(createStore());
expect(store.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
});
});
it("omits exact runtime-only overlays from persisted store writes", async () => {
const credential = createCredential();
resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([
{
profileId: "openai-codex:default",
credential,
},
]);
const { shouldPersistExternalOAuthProfile } = await import("./external-oauth.js");
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai-codex:default": credential }),
profileId: "openai-codex:default",
credential,
});
expect(shouldPersist).toBe(false);
});
it("keeps persisted copies when the external overlay is marked persisted", async () => {
const credential = createCredential();
resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([
{
profileId: "openai-codex:default",
credential,
persistence: "persisted",
},
]);
const { shouldPersistExternalOAuthProfile } = await import("./external-oauth.js");
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai-codex:default": credential }),
profileId: "openai-codex:default",
credential,
});
expect(shouldPersist).toBe(true);
});
it("keeps stale local copies when runtime overlay no longer matches", async () => {
const credential = createCredential();
resolveExternalOAuthProfilesWithPluginsMock.mockReturnValueOnce([
{
profileId: "openai-codex:default",
credential: createCredential({ access: "fresh-access-token" }),
},
]);
const { shouldPersistExternalOAuthProfile } = await import("./external-oauth.js");
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai-codex:default": credential }),
profileId: "openai-codex:default",
credential,
});
expect(shouldPersist).toBe(true);
});
});

View File

@@ -0,0 +1,100 @@
import { resolveExternalOAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js";
import type { ProviderExternalOAuthProfile } from "../../plugins/types.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
type ExternalOAuthProfileMap = Map<string, ProviderExternalOAuthProfile>;
function normalizeExternalOAuthProfile(
profile: ProviderExternalOAuthProfile,
): ProviderExternalOAuthProfile | null {
if (!profile?.profileId || !profile.credential) {
return null;
}
return {
...profile,
persistence: profile.persistence ?? "runtime-only",
};
}
function resolveExternalOAuthProfileMap(params: {
store: AuthProfileStore;
agentDir?: string;
env?: NodeJS.ProcessEnv;
}): ExternalOAuthProfileMap {
const env = params.env ?? process.env;
const profiles = resolveExternalOAuthProfilesWithPlugins({
env,
context: {
config: undefined,
agentDir: params.agentDir,
workspaceDir: undefined,
env,
store: params.store,
},
});
const resolved: ExternalOAuthProfileMap = new Map();
for (const rawProfile of profiles) {
const profile = normalizeExternalOAuthProfile(rawProfile);
if (!profile) {
continue;
}
resolved.set(profile.profileId, profile);
}
return resolved;
}
function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean {
return (
a.type === b.type &&
a.provider === b.provider &&
a.access === b.access &&
a.refresh === b.refresh &&
a.expires === b.expires &&
a.clientId === b.clientId &&
a.email === b.email &&
a.displayName === b.displayName &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId &&
a.managedBy === b.managedBy
);
}
export function overlayExternalOAuthProfiles(
store: AuthProfileStore,
params?: { agentDir?: string; env?: NodeJS.ProcessEnv },
): AuthProfileStore {
const profiles = resolveExternalOAuthProfileMap({
store,
agentDir: params?.agentDir,
env: params?.env,
});
if (profiles.size === 0) {
return store;
}
const next = structuredClone(store);
for (const [profileId, profile] of profiles) {
next.profiles[profileId] = profile.credential;
}
return next;
}
export function shouldPersistExternalOAuthProfile(params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
agentDir?: string;
env?: NodeJS.ProcessEnv;
}): boolean {
const external = resolveExternalOAuthProfileMap({
store: params.store,
agentDir: params.agentDir,
env: params.env,
}).get(params.profileId);
if (!external || external.persistence === "persisted") {
return true;
}
return !oauthCredentialMatches(external.credential, params.credential);
}

View File

@@ -4,6 +4,10 @@ import { coerceSecretRef } from "../../config/types.secrets.js";
import { withFileLock } from "../../infra/file-lock.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import {
overlayExternalOAuthProfiles,
shouldPersistExternalOAuthProfile,
} from "./external-oauth.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type {
AuthProfileCredential,
@@ -348,9 +352,24 @@ function mergeAuthProfileStores(
};
}
function buildPersistedAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
function buildPersistedAuthProfileStore(
store: AuthProfileStore,
params?: { agentDir?: string },
): AuthProfileStore {
const profiles = Object.fromEntries(
Object.entries(store.profiles).flatMap(([profileId, credential]) => {
if (
credential.type === "oauth" &&
!shouldPersistExternalOAuthProfile({
store,
profileId,
credential,
agentDir: params?.agentDir,
})
) {
// Provider-managed external OAuth profiles are runtime-only overlays.
return [];
}
if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) {
const sanitized = { ...credential } as Record<string, unknown>;
delete sanitized.key;
@@ -445,7 +464,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
const authPath = resolveAuthStorePath();
const asStore = loadCoercedStore(authPath);
if (asStore) {
return asStore;
return overlayExternalOAuthProfiles(asStore);
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
const legacy = coerceLegacyStore(legacyRaw);
@@ -455,10 +474,10 @@ export function loadAuthProfileStore(): AuthProfileStore {
profiles: {},
};
applyLegacyStore(store, legacy);
return store;
return overlayExternalOAuthProfiles(store);
}
return { version: AUTH_STORE_VERSION, profiles: {} };
return overlayExternalOAuthProfiles({ version: AUTH_STORE_VERSION, profiles: {} });
}
function loadAuthProfileStoreForAgent(
@@ -543,11 +562,13 @@ export function loadAuthProfileStoreForRuntime(
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
return overlayExternalOAuthProfiles(store, { agentDir });
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store);
return overlayExternalOAuthProfiles(mergeAuthProfileStores(mainStore, store), {
agentDir,
});
}
export function loadAuthProfileStoreForSecretsRuntime(agentDir?: string): AuthProfileStore {
@@ -560,26 +581,26 @@ export function ensureAuthProfileStore(
): AuthProfileStore {
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir);
if (runtimeStore) {
return runtimeStore;
return overlayExternalOAuthProfiles(runtimeStore, { agentDir });
}
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
return overlayExternalOAuthProfiles(store, { agentDir });
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
const merged = mergeAuthProfileStores(mainStore, store);
return merged;
return overlayExternalOAuthProfiles(merged, { agentDir });
}
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
const authPath = resolveAuthStorePath(agentDir);
const runtimeKey = resolveRuntimeStoreKey(agentDir);
const payload = buildPersistedAuthProfileStore(store);
const payload = buildPersistedAuthProfileStore(store, { agentDir });
saveJsonFile(authPath, payload);
const runtimeStore = cloneAuthProfileStore(store);
writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), runtimeStore);

View File

@@ -49,6 +49,7 @@ let resolveProviderDefaultThinkingLevel: typeof import("./provider-runtime.js").
let resolveProviderModernModelRef: typeof import("./provider-runtime.js").resolveProviderModernModelRef;
let resolveProviderReasoningOutputModeWithPlugin: typeof import("./provider-runtime.js").resolveProviderReasoningOutputModeWithPlugin;
let resolveProviderReplayPolicyWithPlugin: typeof import("./provider-runtime.js").resolveProviderReplayPolicyWithPlugin;
let resolveExternalOAuthProfilesWithPlugins: typeof import("./provider-runtime.js").resolveExternalOAuthProfilesWithPlugins;
let resolveProviderSyntheticAuthWithPlugin: typeof import("./provider-runtime.js").resolveProviderSyntheticAuthWithPlugin;
let shouldDeferProviderSyntheticProfileAuthWithPlugin: typeof import("./provider-runtime.js").shouldDeferProviderSyntheticProfileAuthWithPlugin;
let sanitizeProviderReplayHistoryWithPlugin: typeof import("./provider-runtime.js").sanitizeProviderReplayHistoryWithPlugin;
@@ -256,6 +257,7 @@ describe("provider-runtime", () => {
resolveProviderModernModelRef,
resolveProviderReasoningOutputModeWithPlugin,
resolveProviderReplayPolicyWithPlugin,
resolveExternalOAuthProfilesWithPlugins,
resolveProviderSyntheticAuthWithPlugin,
shouldDeferProviderSyntheticProfileAuthWithPlugin,
sanitizeProviderReplayHistoryWithPlugin,
@@ -644,6 +646,23 @@ describe("provider-runtime", () => {
},
createEmbeddingProvider,
resolveSyntheticAuth,
resolveExternalOAuthProfiles: ({ store }) =>
store.profiles["demo:managed"]
? []
: [
{
persistence: "runtime-only",
profileId: "demo:managed",
credential: {
type: "oauth",
provider: DEMO_PROVIDER_ID,
access: "external-access",
refresh: "external-refresh",
expires: Date.now() + 60_000,
managedBy: "demo-cli",
},
},
],
shouldDeferSyntheticProfileAuth,
normalizeResolvedModel: ({ model }) => ({
...model,
@@ -1035,6 +1054,30 @@ describe("provider-runtime", () => {
}),
expected: true,
},
{
actual: () =>
resolveExternalOAuthProfilesWithPlugins({
env: process.env,
context: {
env: process.env,
store: { version: 1, profiles: {} },
},
}),
expected: [
{
persistence: "runtime-only",
profileId: "demo:managed",
credential: {
type: "oauth",
provider: DEMO_PROVIDER_ID,
access: "external-access",
refresh: "external-refresh",
expires: expect.any(Number),
managedBy: "demo-cli",
},
},
],
},
{
actual: () =>
resolveProviderSyntheticAuthWithPlugin({

View File

@@ -16,6 +16,7 @@ import type {
ProviderCacheTtlEligibilityContext,
ProviderCreateEmbeddingProviderContext,
ProviderDeferSyntheticProfileAuthContext,
ProviderExternalOAuthProfile,
ProviderResolveSyntheticAuthContext,
ProviderCreateStreamFnContext,
ProviderDefaultThinkingPolicyContext,
@@ -33,6 +34,7 @@ import type {
ProviderModernModelPolicyContext,
ProviderPrepareExtraParamsContext,
ProviderPrepareDynamicModelContext,
ProviderResolveExternalOAuthProfilesContext,
ProviderPrepareRuntimeAuthContext,
ProviderApplyConfigDefaultsContext,
ProviderResolveConfigApiKeyContext,
@@ -789,6 +791,23 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
return resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(params.context) ?? undefined;
}
export function resolveExternalOAuthProfilesWithPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderResolveExternalOAuthProfilesContext;
}): ProviderExternalOAuthProfile[] {
const matches: ProviderExternalOAuthProfile[] = [];
for (const plugin of resolveProviderPluginsForHooks(params)) {
const profiles = plugin.resolveExternalOAuthProfiles?.(params.context);
if (!profiles || profiles.length === 0) {
continue;
}
matches.push(...profiles);
}
return matches;
}
export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: {
provider: string;
config?: OpenClawConfig;

View File

@@ -1040,6 +1040,20 @@ export type ProviderSyntheticAuthResult = {
mode: Exclude<ModelProviderAuthMode, "aws-sdk">;
};
export type ProviderResolveExternalOAuthProfilesContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
store: AuthProfileStore;
};
export type ProviderExternalOAuthProfile = {
profileId: string;
credential: OAuthCredential;
persistence?: "runtime-only" | "persisted";
};
export type ProviderDeferSyntheticProfileAuthContext = {
config?: OpenClawConfig;
provider: string;
@@ -1517,6 +1531,20 @@ export type ProviderPlugin = {
resolveSyntheticAuth?: (
ctx: ProviderResolveSyntheticAuthContext,
) => ProviderSyntheticAuthResult | null | undefined;
/**
* Provider-owned external OAuth profile discovery.
*
* Use this when credentials are managed by an external tool and should be
* visible to runtime auth resolution without being written back into
* `auth-profiles.json` by core.
*/
resolveExternalOAuthProfiles?: (
ctx: ProviderResolveExternalOAuthProfilesContext,
) =>
| Array<ProviderExternalOAuthProfile>
| ReadonlyArray<ProviderExternalOAuthProfile>
| null
| undefined;
/**
* Provider-owned precedence rule for stored synthetic auth profiles.
*