mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:04:31 +00:00
fix(auth): bidirectional mode/type compat + sync OAuth to all agents (#12692)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 2dee8e1174
Co-authored-by: mudrii <220262+mudrii@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
|
- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
|
||||||
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
|
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
|
||||||
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
|
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
|
||||||
|
- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.
|
||||||
- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
|
- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
|
||||||
- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
|
- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
|
||||||
- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla.
|
- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla.
|
||||||
|
|||||||
@@ -114,6 +114,205 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adopts newer OAuth token from main agent even when secondary token is still valid", async () => {
|
||||||
|
const profileId = "anthropic:claude-cli";
|
||||||
|
const now = Date.now();
|
||||||
|
const secondaryExpiry = now + 30 * 60 * 1000;
|
||||||
|
const mainExpiry = now + 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const secondaryStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "secondary-access-token",
|
||||||
|
refresh: "secondary-refresh-token",
|
||||||
|
expires: secondaryExpiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||||
|
JSON.stringify(secondaryStore),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "main-newer-access-token",
|
||||||
|
refresh: "main-newer-refresh-token",
|
||||||
|
expires: mainExpiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
|
||||||
|
|
||||||
|
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||||
|
const result = await resolveApiKeyForProfile({
|
||||||
|
store: loadedSecondaryStore,
|
||||||
|
profileId,
|
||||||
|
agentDir: secondaryAgentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.apiKey).toBe("main-newer-access-token");
|
||||||
|
|
||||||
|
const updatedSecondaryStore = JSON.parse(
|
||||||
|
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
||||||
|
) as AuthProfileStore;
|
||||||
|
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
||||||
|
access: "main-newer-access-token",
|
||||||
|
expires: mainExpiry,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts main token when secondary expires is NaN/malformed", async () => {
|
||||||
|
const profileId = "anthropic:claude-cli";
|
||||||
|
const now = Date.now();
|
||||||
|
const mainExpiry = now + 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const secondaryStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "secondary-stale",
|
||||||
|
refresh: "secondary-refresh",
|
||||||
|
expires: NaN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||||
|
JSON.stringify(secondaryStore),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "main-fresh-token",
|
||||||
|
refresh: "main-refresh",
|
||||||
|
expires: mainExpiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
|
||||||
|
|
||||||
|
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||||
|
const result = await resolveApiKeyForProfile({
|
||||||
|
store: loadedSecondaryStore,
|
||||||
|
profileId,
|
||||||
|
agentDir: secondaryAgentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.apiKey).toBe("main-fresh-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts mode=token + type=oauth for legacy compatibility", async () => {
|
||||||
|
const profileId = "anthropic:default";
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "oauth-token",
|
||||||
|
refresh: "refresh-token",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolveApiKeyForProfile({
|
||||||
|
cfg: {
|
||||||
|
auth: {
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
provider: "anthropic",
|
||||||
|
mode: "token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
profileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.apiKey).toBe("oauth-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts mode=oauth + type=token (regression)", async () => {
|
||||||
|
const profileId = "anthropic:default";
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "token",
|
||||||
|
provider: "anthropic",
|
||||||
|
token: "static-token",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolveApiKeyForProfile({
|
||||||
|
cfg: {
|
||||||
|
auth: {
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
provider: "anthropic",
|
||||||
|
mode: "oauth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
profileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.apiKey).toBe("static-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects true mode/type mismatches", async () => {
|
||||||
|
const profileId = "anthropic:default";
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "oauth-token",
|
||||||
|
refresh: "refresh-token",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolveApiKeyForProfile({
|
||||||
|
cfg: {
|
||||||
|
auth: {
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
provider: "anthropic",
|
||||||
|
mode: "api_key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
profileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("throws error when both secondary and main agent credentials are expired", async () => {
|
it("throws error when both secondary and main agent credentials are expired", async () => {
|
||||||
const profileId = "anthropic:claude-cli";
|
const profileId = "anthropic:claude-cli";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("resolveApiKeyForProfile config compatibility", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects oauth credentials when config mode is token", async () => {
|
it("accepts oauth credentials when config mode is token (bidirectional compat)", async () => {
|
||||||
const profileId = "anthropic:oauth";
|
const profileId = "anthropic:oauth";
|
||||||
const store: AuthProfileStore = {
|
const store: AuthProfileStore = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -80,7 +80,12 @@ describe("resolveApiKeyForProfile config compatibility", () => {
|
|||||||
store,
|
store,
|
||||||
profileId,
|
profileId,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
// token ↔ oauth are bidirectionally compatible bearer-token auth paths.
|
||||||
|
expect(result).toEqual({
|
||||||
|
apiKey: "access-123",
|
||||||
|
provider: "anthropic",
|
||||||
|
email: undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects credentials when provider does not match config", async () => {
|
it("rejects credentials when provider does not match config", async () => {
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ const isOAuthProvider = (provider: string): provider is OAuthProvider =>
|
|||||||
const resolveOAuthProvider = (provider: string): OAuthProvider | null =>
|
const resolveOAuthProvider = (provider: string): OAuthProvider | null =>
|
||||||
isOAuthProvider(provider) ? provider : null;
|
isOAuthProvider(provider) ? provider : null;
|
||||||
|
|
||||||
|
/** Bearer-token auth modes that are interchangeable (oauth tokens and raw tokens). */
|
||||||
|
const BEARER_AUTH_MODES = new Set(["oauth", "token"]);
|
||||||
|
|
||||||
|
const isCompatibleModeType = (mode: string | undefined, type: string | undefined): boolean => {
|
||||||
|
if (!mode || !type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mode === type) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Both token and oauth represent bearer-token auth paths — allow bidirectional compat.
|
||||||
|
return BEARER_AUTH_MODES.has(mode) && BEARER_AUTH_MODES.has(type);
|
||||||
|
};
|
||||||
|
|
||||||
function isProfileConfigCompatible(params: {
|
function isProfileConfigCompatible(params: {
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
@@ -34,16 +48,8 @@ function isProfileConfigCompatible(params: {
|
|||||||
if (profileConfig && profileConfig.provider !== params.provider) {
|
if (profileConfig && profileConfig.provider !== params.provider) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (profileConfig && profileConfig.mode !== params.mode) {
|
if (profileConfig && !isCompatibleModeType(profileConfig.mode, params.mode)) {
|
||||||
if (
|
return false;
|
||||||
!(
|
|
||||||
params.allowOAuthTokenCompatibility &&
|
|
||||||
profileConfig.mode === "oauth" &&
|
|
||||||
params.mode === "token"
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -91,6 +97,43 @@ type ResolveApiKeyForProfileParams = {
|
|||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function adoptNewerMainOAuthCredential(params: {
|
||||||
|
store: AuthProfileStore;
|
||||||
|
profileId: string;
|
||||||
|
agentDir?: string;
|
||||||
|
cred: OAuthCredentials & { type: "oauth"; provider: string; email?: string };
|
||||||
|
}): (OAuthCredentials & { type: "oauth"; provider: string; email?: string }) | null {
|
||||||
|
if (!params.agentDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mainStore = ensureAuthProfileStore(undefined);
|
||||||
|
const mainCred = mainStore.profiles[params.profileId];
|
||||||
|
if (
|
||||||
|
mainCred?.type === "oauth" &&
|
||||||
|
mainCred.provider === params.cred.provider &&
|
||||||
|
Number.isFinite(mainCred.expires) &&
|
||||||
|
(!Number.isFinite(params.cred.expires) || mainCred.expires > params.cred.expires)
|
||||||
|
) {
|
||||||
|
params.store.profiles[params.profileId] = { ...mainCred };
|
||||||
|
saveAuthProfileStore(params.store, params.agentDir);
|
||||||
|
log.info("adopted newer OAuth credentials from main agent", {
|
||||||
|
profileId: params.profileId,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
expires: new Date(mainCred.expires).toISOString(),
|
||||||
|
});
|
||||||
|
return mainCred;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort: don't crash if main agent store is missing or unreadable.
|
||||||
|
log.debug("adoptNewerMainOAuthCredential failed", {
|
||||||
|
profileId: params.profileId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshOAuthTokenWithLock(params: {
|
async function refreshOAuthTokenWithLock(params: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
@@ -229,11 +272,20 @@ export async function resolveApiKeyForProfile(
|
|||||||
}
|
}
|
||||||
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
|
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
|
||||||
}
|
}
|
||||||
if (Date.now() < cred.expires) {
|
|
||||||
|
const oauthCred =
|
||||||
|
adoptNewerMainOAuthCredential({
|
||||||
|
store,
|
||||||
|
profileId,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
cred,
|
||||||
|
}) ?? cred;
|
||||||
|
|
||||||
|
if (Date.now() < oauthCred.expires) {
|
||||||
return buildOAuthProfileResult({
|
return buildOAuthProfileResult({
|
||||||
provider: cred.provider,
|
provider: oauthCred.provider,
|
||||||
credentials: cred,
|
credentials: oauthCred,
|
||||||
email: cred.email,
|
email: oauthCred.email,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type AuthProfileFailureReason =
|
|||||||
| "rate_limit"
|
| "rate_limit"
|
||||||
| "billing"
|
| "billing"
|
||||||
| "timeout"
|
| "timeout"
|
||||||
|
| "model_not_found"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine
|
|||||||
return 408;
|
return 408;
|
||||||
case "format":
|
case "format":
|
||||||
return 400;
|
return 400;
|
||||||
|
case "model_not_found":
|
||||||
|
return 404;
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,49 @@ describe("runWithModelFallback", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back on unknown model errors", async () => {
|
||||||
|
const cfg = makeCfg();
|
||||||
|
const run = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("Unknown model: anthropic/claude-opus-4-6"))
|
||||||
|
.mockResolvedValueOnce("ok");
|
||||||
|
|
||||||
|
const result = await runWithModelFallback({
|
||||||
|
cfg,
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-6",
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override model failed with model_not_found → falls back to configured primary.
|
||||||
|
// (Same candidate-resolution path as other override-model failures.)
|
||||||
|
expect(result.result).toBe("ok");
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run.mock.calls[1]?.[0]).toBe("openai");
|
||||||
|
expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back on model not found errors", async () => {
|
||||||
|
const cfg = makeCfg();
|
||||||
|
const run = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("Model not found: openai/gpt-6"))
|
||||||
|
.mockResolvedValueOnce("ok");
|
||||||
|
|
||||||
|
const result = await runWithModelFallback({
|
||||||
|
cfg,
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-6",
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override model failed with model_not_found → falls back to configured primary.
|
||||||
|
expect(result.result).toBe("ok");
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run.mock.calls[1]?.[0]).toBe("openai");
|
||||||
|
expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini");
|
||||||
|
});
|
||||||
|
|
||||||
it("skips providers when all profiles are in cooldown", async () => {
|
it("skips providers when all profiles are in cooldown", async () => {
|
||||||
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
||||||
const profileId = `${provider}:default`;
|
const profileId = `${provider}:default`;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export {
|
|||||||
getApiErrorPayloadFingerprint,
|
getApiErrorPayloadFingerprint,
|
||||||
isAuthAssistantError,
|
isAuthAssistantError,
|
||||||
isAuthErrorMessage,
|
isAuthErrorMessage,
|
||||||
|
isModelNotFoundErrorMessage,
|
||||||
isBillingAssistantError,
|
isBillingAssistantError,
|
||||||
parseApiErrorInfo,
|
parseApiErrorInfo,
|
||||||
sanitizeUserFacingText,
|
sanitizeUserFacingText,
|
||||||
|
|||||||
@@ -765,6 +765,37 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
|
|||||||
return isAuthErrorMessage(msg.errorMessage ?? "");
|
return isAuthErrorMessage(msg.errorMessage ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isModelNotFoundErrorMessage(raw: string): boolean {
|
||||||
|
if (!raw) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
|
||||||
|
// Direct pattern matches from OpenClaw internals and common providers.
|
||||||
|
if (
|
||||||
|
lower.includes("unknown model") ||
|
||||||
|
lower.includes("model not found") ||
|
||||||
|
lower.includes("model_not_found") ||
|
||||||
|
lower.includes("not_found_error") ||
|
||||||
|
(lower.includes("does not exist") && lower.includes("model")) ||
|
||||||
|
(lower.includes("invalid model") && !lower.includes("invalid model reference"))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Gemini: "models/X is not found for api version"
|
||||||
|
if (/models\/[^\s]+ is not found/i.test(raw)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON error payloads: {"status": "NOT_FOUND"} or {"code": 404} combined with not-found text.
|
||||||
|
if (/\b404\b/.test(raw) && /not[-_ ]?found/i.test(raw)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function classifyFailoverReason(raw: string): FailoverReason | null {
|
export function classifyFailoverReason(raw: string): FailoverReason | null {
|
||||||
if (isImageDimensionErrorMessage(raw)) {
|
if (isImageDimensionErrorMessage(raw)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -772,6 +803,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null {
|
|||||||
if (isImageSizeError(raw)) {
|
if (isImageSizeError(raw)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isModelNotFoundErrorMessage(raw)) {
|
||||||
|
return "model_not_found";
|
||||||
|
}
|
||||||
if (isTransientHttpError(raw)) {
|
if (isTransientHttpError(raw)) {
|
||||||
// Treat transient 5xx provider failures as retryable transport issues.
|
// Treat transient 5xx provider failures as retryable transport issues.
|
||||||
return "timeout";
|
return "timeout";
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
export type EmbeddedContextFile = { path: string; content: string };
|
export type EmbeddedContextFile = { path: string; content: string };
|
||||||
|
|
||||||
export type FailoverReason = "auth" | "format" | "rate_limit" | "billing" | "timeout" | "unknown";
|
export type FailoverReason =
|
||||||
|
| "auth"
|
||||||
|
| "format"
|
||||||
|
| "rate_limit"
|
||||||
|
| "billing"
|
||||||
|
| "timeout"
|
||||||
|
| "model_not_found"
|
||||||
|
| "unknown";
|
||||||
|
|||||||
@@ -274,7 +274,11 @@ export async function runEmbeddedPiAgent(
|
|||||||
params.config,
|
params.config,
|
||||||
);
|
);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
throw new FailoverError(error ?? `Unknown model: ${provider}/${modelId}`, {
|
||||||
|
reason: "model_not_found",
|
||||||
|
provider,
|
||||||
|
model: modelId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctxInfo = resolveContextWindowInfo({
|
const ctxInfo = resolveContextWindowInfo({
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ export async function applyAuthChoiceOpenAI(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
if (creds) {
|
if (creds) {
|
||||||
const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir);
|
const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir, {
|
||||||
|
syncSiblingAgents: true,
|
||||||
|
});
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId,
|
profileId,
|
||||||
provider: "openai-codex",
|
provider: "openai-codex",
|
||||||
|
|||||||
@@ -1,28 +1,112 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||||
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
|
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
|
||||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js";
|
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js";
|
||||||
export { XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js";
|
export { XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js";
|
||||||
|
|
||||||
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
|
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
|
||||||
|
|
||||||
|
export type WriteOAuthCredentialsOptions = {
|
||||||
|
syncSiblingAgents?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve real path, returning null if the target doesn't exist. */
|
||||||
|
function safeRealpathSync(dir: string): string | null {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(path.resolve(dir));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSiblingAgentDirs(primaryAgentDir: string): string[] {
|
||||||
|
const normalized = path.resolve(primaryAgentDir);
|
||||||
|
|
||||||
|
// Derive agentsRoot from primaryAgentDir when it matches the standard
|
||||||
|
// layout (.../agents/<name>/agent). Falls back to global state dir.
|
||||||
|
const parentOfAgent = path.dirname(normalized);
|
||||||
|
const candidateAgentsRoot = path.dirname(parentOfAgent);
|
||||||
|
const looksLikeStandardLayout =
|
||||||
|
path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents";
|
||||||
|
|
||||||
|
const agentsRoot = looksLikeStandardLayout
|
||||||
|
? candidateAgentsRoot
|
||||||
|
: path.join(resolveStateDir(), "agents");
|
||||||
|
|
||||||
|
const entries = (() => {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(agentsRoot, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Include both directories and symlinks-to-directories.
|
||||||
|
const discovered = entries
|
||||||
|
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||||
|
.map((entry) => path.join(agentsRoot, entry.name, "agent"));
|
||||||
|
|
||||||
|
// Deduplicate via realpath to handle symlinks and path normalization.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const dir of [normalized, ...discovered]) {
|
||||||
|
const real = safeRealpathSync(dir);
|
||||||
|
if (real && !seen.has(real)) {
|
||||||
|
seen.add(real);
|
||||||
|
result.push(real);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeOAuthCredentials(
|
export async function writeOAuthCredentials(
|
||||||
provider: string,
|
provider: string,
|
||||||
creds: OAuthCredentials,
|
creds: OAuthCredentials,
|
||||||
agentDir?: string,
|
agentDir?: string,
|
||||||
|
options?: WriteOAuthCredentialsOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const email =
|
const email =
|
||||||
typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default";
|
typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default";
|
||||||
const profileId = `${provider}:${email}`;
|
const profileId = `${provider}:${email}`;
|
||||||
|
const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir));
|
||||||
|
const targetAgentDirs = options?.syncSiblingAgents
|
||||||
|
? resolveSiblingAgentDirs(resolvedAgentDir)
|
||||||
|
: [resolvedAgentDir];
|
||||||
|
|
||||||
|
const credential = {
|
||||||
|
type: "oauth" as const,
|
||||||
|
provider,
|
||||||
|
...creds,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Primary write must succeed — let it throw on failure.
|
||||||
upsertAuthProfile({
|
upsertAuthProfile({
|
||||||
profileId,
|
profileId,
|
||||||
credential: {
|
credential,
|
||||||
type: "oauth",
|
agentDir: resolvedAgentDir,
|
||||||
provider,
|
|
||||||
...creds,
|
|
||||||
},
|
|
||||||
agentDir: resolveAuthAgentDir(agentDir),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sibling sync is best-effort — log and ignore individual failures.
|
||||||
|
if (options?.syncSiblingAgents) {
|
||||||
|
const primaryReal = safeRealpathSync(resolvedAgentDir);
|
||||||
|
for (const targetAgentDir of targetAgentDirs) {
|
||||||
|
const targetReal = safeRealpathSync(targetAgentDir);
|
||||||
|
if (targetReal && primaryReal && targetReal === primaryReal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
upsertAuthProfile({
|
||||||
|
profileId,
|
||||||
|
credential,
|
||||||
|
agentDir: targetAgentDir,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort: sibling sync failure must not block primary onboarding.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return profileId;
|
return profileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
@@ -111,6 +112,9 @@ describe("writeOAuthCredentials", () => {
|
|||||||
"OPENCLAW_OAUTH_DIR",
|
"OPENCLAW_OAUTH_DIR",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let tempStateDir: string;
|
||||||
|
const authProfilePathFor = (dir: string) => path.join(dir, "auth-profiles.json");
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await lifecycle.cleanup();
|
await lifecycle.cleanup();
|
||||||
});
|
});
|
||||||
@@ -125,13 +129,12 @@ describe("writeOAuthCredentials", () => {
|
|||||||
expires: Date.now() + 60_000,
|
expires: Date.now() + 60_000,
|
||||||
} satisfies OAuthCredentials;
|
} satisfies OAuthCredentials;
|
||||||
|
|
||||||
const profileId = await writeOAuthCredentials("openai-codex", creds);
|
await writeOAuthCredentials("openai-codex", creds);
|
||||||
expect(profileId).toBe("openai-codex:default");
|
|
||||||
|
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
const parsed = await readAuthProfilesForAgent<{
|
||||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||||
}>(env.agentDir);
|
}>(env.agentDir);
|
||||||
expect(parsed.profiles?.[profileId]).toMatchObject({
|
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
|
||||||
refresh: "refresh-token",
|
refresh: "refresh-token",
|
||||||
access: "access-token",
|
access: "access-token",
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@@ -142,30 +145,114 @@ describe("writeOAuthCredentials", () => {
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses OAuth email as profile id when provided", async () => {
|
it("writes OAuth credentials to all sibling agent dirs when syncSiblingAgents=true", async () => {
|
||||||
const env = await setupAuthTestEnv("openclaw-oauth-");
|
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-sync-"));
|
||||||
lifecycle.setStateDir(env.stateDir);
|
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||||
|
|
||||||
|
const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent");
|
||||||
|
const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent");
|
||||||
|
const workerAgentDir = path.join(tempStateDir, "agents", "worker", "agent");
|
||||||
|
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||||
|
await fs.mkdir(kidAgentDir, { recursive: true });
|
||||||
|
await fs.mkdir(workerAgentDir, { recursive: true });
|
||||||
|
|
||||||
|
process.env.OPENCLAW_AGENT_DIR = kidAgentDir;
|
||||||
|
process.env.PI_CODING_AGENT_DIR = kidAgentDir;
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
email: "user@example.com",
|
refresh: "refresh-sync",
|
||||||
refresh: "refresh-token",
|
access: "access-sync",
|
||||||
access: "access-token",
|
|
||||||
expires: Date.now() + 60_000,
|
expires: Date.now() + 60_000,
|
||||||
} satisfies OAuthCredentials;
|
} satisfies OAuthCredentials;
|
||||||
|
|
||||||
const profileId = await writeOAuthCredentials("openai-codex", creds);
|
await writeOAuthCredentials("openai-codex", creds, undefined, {
|
||||||
expect(profileId).toBe("openai-codex:user@example.com");
|
syncSiblingAgents: true,
|
||||||
|
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
|
||||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
|
||||||
}>(env.agentDir);
|
|
||||||
expect(parsed.profiles?.[profileId]).toMatchObject({
|
|
||||||
refresh: "refresh-token",
|
|
||||||
access: "access-token",
|
|
||||||
type: "oauth",
|
|
||||||
provider: "openai-codex",
|
|
||||||
email: "user@example.com",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const dir of [mainAgentDir, kidAgentDir, workerAgentDir]) {
|
||||||
|
const raw = await fs.readFile(authProfilePathFor(dir), "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||||
|
};
|
||||||
|
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
|
||||||
|
refresh: "refresh-sync",
|
||||||
|
access: "access-sync",
|
||||||
|
type: "oauth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes OAuth credentials only to target dir by default", async () => {
|
||||||
|
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-nosync-"));
|
||||||
|
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||||
|
|
||||||
|
const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent");
|
||||||
|
const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent");
|
||||||
|
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||||
|
await fs.mkdir(kidAgentDir, { recursive: true });
|
||||||
|
|
||||||
|
process.env.OPENCLAW_AGENT_DIR = kidAgentDir;
|
||||||
|
process.env.PI_CODING_AGENT_DIR = kidAgentDir;
|
||||||
|
|
||||||
|
const creds = {
|
||||||
|
refresh: "refresh-kid",
|
||||||
|
access: "access-kid",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
} satisfies OAuthCredentials;
|
||||||
|
|
||||||
|
await writeOAuthCredentials("openai-codex", creds, kidAgentDir);
|
||||||
|
|
||||||
|
const kidRaw = await fs.readFile(authProfilePathFor(kidAgentDir), "utf8");
|
||||||
|
const kidParsed = JSON.parse(kidRaw) as {
|
||||||
|
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||||
|
};
|
||||||
|
expect(kidParsed.profiles?.["openai-codex:default"]).toMatchObject({
|
||||||
|
access: "access-kid",
|
||||||
|
type: "oauth",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.readFile(authProfilePathFor(mainAgentDir), "utf8")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs siblings from explicit agentDir outside OPENCLAW_STATE_DIR", async () => {
|
||||||
|
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-external-"));
|
||||||
|
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||||
|
|
||||||
|
// Create standard-layout agents tree *outside* OPENCLAW_STATE_DIR
|
||||||
|
const externalRoot = path.join(tempStateDir, "external", "agents");
|
||||||
|
const extMain = path.join(externalRoot, "main", "agent");
|
||||||
|
const extKid = path.join(externalRoot, "kid", "agent");
|
||||||
|
const extWorker = path.join(externalRoot, "worker", "agent");
|
||||||
|
await fs.mkdir(extMain, { recursive: true });
|
||||||
|
await fs.mkdir(extKid, { recursive: true });
|
||||||
|
await fs.mkdir(extWorker, { recursive: true });
|
||||||
|
|
||||||
|
const creds = {
|
||||||
|
refresh: "refresh-ext",
|
||||||
|
access: "access-ext",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
} satisfies OAuthCredentials;
|
||||||
|
|
||||||
|
await writeOAuthCredentials("openai-codex", creds, extKid, {
|
||||||
|
syncSiblingAgents: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// All siblings under the external root should have credentials
|
||||||
|
for (const dir of [extMain, extKid, extWorker]) {
|
||||||
|
const raw = await fs.readFile(authProfilePathFor(dir), "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||||
|
};
|
||||||
|
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
|
||||||
|
refresh: "refresh-ext",
|
||||||
|
access: "access-ext",
|
||||||
|
type: "oauth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global state dir should NOT have credentials written
|
||||||
|
const globalMain = path.join(tempStateDir, "agents", "main", "agent");
|
||||||
|
await expect(fs.readFile(authProfilePathFor(globalMain), "utf8")).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user