refactor(auth): tighten external oauth bootstrap policy

This commit is contained in:
Vincent Koc
2026-04-17 14:04:52 -07:00
parent 99ef3a63c5
commit f61712437f
11 changed files with 204 additions and 121 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)

View File

@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential;
let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles;
let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential;
let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential;
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential;
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
@@ -45,8 +47,10 @@ describe("external cli oauth resolution", () => {
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
({
hasUsableOAuthCredential,
readManagedExternalCliCredential,
resolveExternalCliAuthProfiles,
shouldBootstrapFromExternalCliCredential,
shouldReplaceStoredOAuthCredential,
} = await import("./auth-profiles/external-cli-sync.js"));
({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
@@ -104,6 +108,70 @@ describe("external cli oauth resolution", () => {
});
});
describe("external cli bootstrap policy", () => {
it("treats only non-expired access tokens as usable local oauth", () => {
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai-codex",
access: "live-access",
expires: Date.now() + 60_000,
}),
),
).toBe(true);
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai-codex",
access: "expired-access",
expires: Date.now() - 60_000,
}),
),
).toBe(false);
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai-codex",
access: "",
expires: Date.now() + 60_000,
}),
),
).toBe(false);
});
it("only bootstraps from external cli when the stored oauth is not usable", () => {
const imported = makeOAuthCredential({
provider: "openai-codex",
access: "fresh-cli-access",
refresh: "fresh-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
expect(
shouldBootstrapFromExternalCliCredential({
existing: makeOAuthCredential({
provider: "openai-codex",
access: "healthy-local-access",
refresh: "healthy-local-refresh",
expires: Date.now() + 60_000,
}),
imported,
}),
).toBe(false);
expect(
shouldBootstrapFromExternalCliCredential({
existing: makeOAuthCredential({
provider: "openai-codex",
access: "expired-local-access",
refresh: "expired-local-refresh",
expires: Date.now() - 60_000,
}),
imported,
}),
).toBe(true);
});
});
it("reads codex external cli credentials by profile id", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
@@ -210,4 +278,29 @@ describe("external cli oauth resolution", () => {
expect(profiles).toEqual([]);
});
it("does not overlay fresh external cli oauth over a still-usable local credential", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "fresh-cli-access",
refresh: "fresh-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
makeOAuthCredential({
provider: "openai-codex",
access: "healthy-local-access",
refresh: "healthy-local-refresh",
expires: Date.now() + 60_000,
}),
),
);
expect(profiles).toEqual([]);
});
});

View File

@@ -131,41 +131,6 @@ describe("saveAuthProfileStore", () => {
}
});
it("does not persist compatibility-only external oauth ownership metadata", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-managedby-"));
try {
const store: AuthProfileStore = {
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: 123,
managedBy: "codex-cli",
},
},
};
saveAuthProfileStore(store, agentDir);
const persisted = JSON.parse(await fs.readFile(resolveAuthStorePath(agentDir), "utf8")) as {
profiles: Record<string, Record<string, unknown>>;
};
expect(persisted.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: 123,
});
expect(persisted.profiles["openai-codex:default"]?.managedBy).toBeUndefined();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("writes runtime scheduling state to auth-state.json only", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-"));
try {

View File

@@ -1,6 +1,7 @@
import {
hasUsableOAuthCredential,
readManagedExternalCliCredential,
shouldReplaceStoredOAuthCredential,
shouldBootstrapFromExternalCliCredential,
} from "./external-cli-sync.js";
import type { OAuthCredential } from "./types.js";
@@ -15,7 +16,13 @@ export function resolveEffectiveOAuthCredential(params: {
if (!imported) {
return params.credential;
}
return shouldReplaceStoredOAuthCredential(params.credential, imported)
if (hasUsableOAuthCredential(params.credential)) {
return params.credential;
}
return shouldBootstrapFromExternalCliCredential({
existing: params.credential,
imported,
})
? imported
: params.credential;
}

View File

@@ -7,6 +7,7 @@ import {
MINIMAX_CLI_PROFILE_ID,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
} from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
export type ExternalCliResolvedProfile = {
@@ -67,6 +68,31 @@ export function shouldReplaceStoredOAuthCredential(
return !hasNewerStoredOAuthCredential(existing, incoming);
}
export function hasUsableOAuthCredential(
credential: OAuthCredential | undefined,
now = Date.now(),
): boolean {
if (!credential || credential.type !== "oauth") {
return false;
}
if (typeof credential.access !== "string" || credential.access.trim().length === 0) {
return false;
}
return resolveTokenExpiryState(credential.expires, now) === "valid";
}
export function shouldBootstrapFromExternalCliCredential(params: {
existing: OAuthCredential | undefined;
imported: OAuthCredential;
now?: number;
}): boolean {
const now = params.now ?? Date.now();
if (hasUsableOAuthCredential(params.existing, now)) {
return false;
}
return hasUsableOAuthCredential(params.imported, now);
}
const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
{
profileId: MINIMAX_CLI_PROFILE_ID,
@@ -111,6 +137,7 @@ export function resolveExternalCliAuthProfiles(
store: AuthProfileStore,
): ExternalCliResolvedProfile[] {
const profiles: ExternalCliResolvedProfile[] = [];
const now = Date.now();
for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) {
const creds = providerConfig.readCredentials();
if (!creds) {
@@ -119,8 +146,11 @@ export function resolveExternalCliAuthProfiles(
const existing = store.profiles[providerConfig.profileId];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
if (
!shouldReplaceStoredOAuthCredential(existingOAuth, creds) &&
!areOAuthCredentialsEquivalent(existingOAuth, creds)
!shouldBootstrapFromExternalCliCredential({
existing: existingOAuth,
imported: creds,
now,
})
) {
continue;
}

View File

@@ -119,12 +119,12 @@ describe("auth external oauth helpers", () => {
expect(shouldPersist).toBe(true);
});
it("overlays fresher external CLI OAuth credentials without treating them as persisted store state", () => {
it("overlays external CLI OAuth only when the stored credential is no longer usable", () => {
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
expires: 456,
expires: Date.now() + 60_000,
}),
);
@@ -133,7 +133,7 @@ describe("auth external oauth helpers", () => {
"openai-codex:default": createCredential({
access: "stale-store-access-token",
refresh: "stale-store-refresh-token",
expires: 123,
expires: Date.now() - 60_000,
}),
}),
);
@@ -141,15 +141,32 @@ describe("auth external oauth helpers", () => {
expect(overlaid.profiles["openai-codex:default"]).toMatchObject({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
expires: 456,
expires: expect.any(Number),
});
});
const shouldPersist = shouldPersistExternalOAuthProfile({
store: overlaid,
profileId: "openai-codex:default",
credential: overlaid.profiles["openai-codex:default"] as OAuthCredential,
it("keeps healthy local oauth even when external cli has a fresher token", () => {
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
expires: Date.now() + 5 * 24 * 60 * 60_000,
}),
);
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai-codex:default": createCredential({
access: "healthy-local-access-token",
refresh: "healthy-local-refresh-token",
expires: Date.now() + 60_000,
}),
}),
);
expect(overlaid.profiles["openai-codex:default"]).toMatchObject({
access: "healthy-local-access-token",
refresh: "healthy-local-refresh-token",
});
expect(shouldPersist).toBe(false);
});
});

View File

@@ -312,11 +312,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
refresh: "rotated-cli-refresh-token",
accountId: "acct-rotated",
});
expect(persisted.profiles[profileId]).not.toEqual(
expect.objectContaining({
managedBy: "codex-cli",
}),
);
expect(persisted.profiles[profileId]).not.toEqual(
expect.objectContaining({
provider: "openai-codex",
@@ -325,6 +320,47 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
);
});
it("keeps healthy local Codex OAuth over fresher imported CLI credentials", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai-codex",
access: "healthy-local-access-token",
refresh: "healthy-local-refresh-token",
expires: Date.now() + 60_000,
},
},
},
agentDir,
);
readCodexCliCredentialsCachedMock.mockReturnValueOnce({
type: "oauth",
provider: "openai-codex",
access: "fresher-cli-access-token",
refresh: "fresher-cli-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-cli",
});
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
).resolves.toEqual({
apiKey: "healthy-local-access-token",
provider: "openai-codex",
email: undefined,
});
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("keeps the canonical refresh token when imported Codex CLI state is stale", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(

View File

@@ -152,13 +152,6 @@ function hasOAuthCredentialChanged(
);
}
function clearExternalOAuthManager(
credential: OAuthCredential,
): OAuthCredentials & { type: "oauth"; provider: string; email?: string } {
const { managedBy: _managedBy, ...canonicalCredential } = credential;
return canonicalCredential;
}
async function loadFreshStoredOAuthCredential(params: {
profileId: string;
agentDir?: string;
@@ -656,7 +649,7 @@ async function doRefreshOAuthTokenWithLock(params: {
);
if (pluginRefreshed) {
const refreshedCredentials: OAuthCredential = {
...clearExternalOAuthManager(cred),
...cred,
...pluginRefreshed,
type: "oauth",
};

View File

@@ -192,10 +192,6 @@ export function buildPersistedAuthProfileSecretsStore(
if (shouldPersistProfile && !shouldPersistProfile({ profileId, credential })) {
return [];
}
if (credential.type === "oauth" && credential.managedBy) {
const { managedBy: _managedBy, ...canonicalCredential } = credential;
return [[profileId, canonicalCredential]];
}
if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) {
const sanitized = { ...credential } as Record<string, unknown>;
delete sanitized.key;

View File

@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SecretRef } from "../../config/types.secrets.js";
export type OAuthProvider = string;
export type ExternalOAuthManager = "codex-cli" | "minimax-cli";
export type OAuthCredentials = {
access: string;
@@ -47,14 +46,6 @@ export type OAuthCredential = OAuthCredentials & {
clientId?: string;
email?: string;
displayName?: string;
/**
* Compatibility/runtime metadata for CLI-managed OAuth entries.
*
* Core routing should prefer external-auth overlay contracts over direct
* branching on this field. Persisted stores may still carry it while older
* CLI sync paths remain supported.
*/
managedBy?: ExternalOAuthManager;
};
export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential;

View File

@@ -141,50 +141,4 @@ describe("resolveCliAuthEpoch", () => {
expect(second).not.toBe(first);
expect(third).not.toBe(second);
});
it("ignores compatibility-only managedBy metadata on auth profiles", async () => {
let store: AuthProfileStore = {
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "profile-access",
refresh: "profile-refresh",
expires: 1,
managedBy: "codex-cli",
},
},
};
setCliAuthEpochTestDeps({
loadAuthProfileStoreForRuntime: () => store,
});
const first = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai-codex:default",
});
store = {
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "profile-access",
refresh: "profile-refresh",
expires: 1,
},
},
};
const second = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai-codex:default",
});
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(second).toBe(first);
});
});