diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e878e6a0d0..58fae42de28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 06460b47edc..2c58ebf561a 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -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([]); + }); }); diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index cc647c9dd9a..3bd309986af 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -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>; - }; - 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 { diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts index 8768982acd8..02743e5204d 100644 --- a/src/agents/auth-profiles/effective-oauth.ts +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -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; } diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 62f3e3cc4de..23930189be6 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -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; } diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index d1f6302937c..568ae2f8031 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -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); }); }); diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 273d1605be8..7961394688d 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -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( diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index a7c7d9a0a0c..f1c8878930a 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -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", }; diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 7aa71a6684e..cfc1a10aae8 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -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; delete sanitized.key; diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index ee62b2b5880..0ee11504310 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -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; diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 97e3d7e325c..6f9d860888c 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -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); - }); });