mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
refactor(auth): tighten external oauth bootstrap policy
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user