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:
mudrii
2026-02-20 18:31:09 +08:00
committed by GitHub
parent 083298ab9d
commit 7ecfc1d93c
14 changed files with 568 additions and 46 deletions

View File

@@ -117,7 +117,9 @@ export async function applyAuthChoiceOpenAI(
return { config: nextConfig, agentModelOverride };
}
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, {
profileId,
provider: "openai-codex",

View File

@@ -1,28 +1,112 @@
import fs from "node:fs";
import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveOpenClawAgentDir } from "../agents/agent-paths.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 { XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js";
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(
provider: string,
creds: OAuthCredentials,
agentDir?: string,
options?: WriteOAuthCredentialsOptions,
): Promise<string> {
const email =
typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default";
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({
profileId,
credential: {
type: "oauth",
provider,
...creds,
},
agentDir: resolveAuthAgentDir(agentDir),
credential,
agentDir: resolvedAgentDir,
});
// 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;
}

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
@@ -111,6 +112,9 @@ describe("writeOAuthCredentials", () => {
"OPENCLAW_OAUTH_DIR",
]);
let tempStateDir: string;
const authProfilePathFor = (dir: string) => path.join(dir, "auth-profiles.json");
afterEach(async () => {
await lifecycle.cleanup();
});
@@ -125,13 +129,12 @@ describe("writeOAuthCredentials", () => {
expires: Date.now() + 60_000,
} satisfies OAuthCredentials;
const profileId = await writeOAuthCredentials("openai-codex", creds);
expect(profileId).toBe("openai-codex:default");
await writeOAuthCredentials("openai-codex", creds);
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, OAuthCredentials & { type?: string }>;
}>(env.agentDir);
expect(parsed.profiles?.[profileId]).toMatchObject({
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
refresh: "refresh-token",
access: "access-token",
type: "oauth",
@@ -142,30 +145,114 @@ describe("writeOAuthCredentials", () => {
).rejects.toThrow();
});
it("uses OAuth email as profile id when provided", async () => {
const env = await setupAuthTestEnv("openclaw-oauth-");
lifecycle.setStateDir(env.stateDir);
it("writes OAuth credentials to all sibling agent dirs when syncSiblingAgents=true", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-sync-"));
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 = {
email: "user@example.com",
refresh: "refresh-token",
access: "access-token",
refresh: "refresh-sync",
access: "access-sync",
expires: Date.now() + 60_000,
} satisfies OAuthCredentials;
const profileId = await writeOAuthCredentials("openai-codex", creds);
expect(profileId).toBe("openai-codex:user@example.com");
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",
await writeOAuthCredentials("openai-codex", creds, undefined, {
syncSiblingAgents: true,
});
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();
});
});