fix(agents): keep OAuth auth read-through

This commit is contained in:
Peter Steinberger
2026-04-29 11:54:13 +01:00
parent 21a92ea0f6
commit e6cd90e3fd
37 changed files with 1306 additions and 127 deletions

View File

@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
} from "openclaw/plugin-sdk/agent-runtime";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -72,7 +76,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
if (refreshed?.access) {
oauthCredential = refreshed as typeof oauthCredential;
params.store.profiles[params.profileId] = oauthCredential;
if (params.agentDir) {
if (params.agentDir || process.env.OPENCLAW_STATE_DIR) {
actual.saveAuthProfileStore(params.store, params.agentDir);
}
}
@@ -92,6 +96,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
afterEach(() => {
vi.unstubAllEnvs();
clearRuntimeAuthProfileStoreSnapshots();
oauthMocks.refreshOpenAICodexToken.mockReset();
providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin.mockReset();
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
@@ -635,6 +640,132 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("refreshes inherited main Codex OAuth without cloning it into the child store", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const stateDir = path.join(root, "state");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main-refreshed",
});
try {
upsertAuthProfile({
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "main-current-access-token",
refresh: "main-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
});
await expect(
refreshCodexAppServerAuthTokens({
agentDir: childAgentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
accessToken: "main-refreshed-access-token",
chatgptAccountId: "account-main-refreshed",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-refresh-token");
await expect(fs.access(childAuthPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(loadAuthProfileStoreForSecretsRuntime().profiles["openai-codex:work"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("force-refreshes the owner credential instead of a stale child OAuth clone", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const stateDir = path.join(root, "state");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main-refreshed",
});
try {
upsertAuthProfile({
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "main-current-access-token",
refresh: "main-owner-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
});
await fs.mkdir(childAgentDir, { recursive: true });
await fs.writeFile(
childAuthPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:work": {
type: "oauth",
provider: "openai-codex",
access: "child-stale-access-token",
refresh: "child-stale-refresh-token",
expires: Date.now() - 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
},
}),
);
await expect(
refreshCodexAppServerAuthTokens({
agentDir: childAgentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
accessToken: "main-refreshed-access-token",
chatgptAccountId: "account-main-refreshed",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-owner-refresh-token");
expect(loadAuthProfileStoreForSecretsRuntime().profiles["openai-codex:work"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
const child = JSON.parse(await fs.readFile(childAuthPath, "utf8")) as {
profiles: Record<string, { access?: string; refresh?: string }>;
};
expect(child.profiles["openai-codex:work"]).toMatchObject({
access: "child-stale-access-token",
refresh: "child-stale-refresh-token",
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("accepts a refreshed Codex OAuth credential when the stored provider is a legacy alias", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({

View File

@@ -3,6 +3,7 @@ import {
loadAuthProfileStoreForSecretsRuntime,
resolveProviderIdForAuth,
resolveApiKeyForProfile,
resolvePersistedAuthProfileOwnerAgentDir,
saveAuthProfileStore,
type AuthProfileCredential,
type OAuthCredential,
@@ -178,17 +179,26 @@ async function resolveOAuthCredentialForCodexAppServer(
credential: OAuthCredential,
params: { agentDir: string; forceRefresh: boolean },
): Promise<OAuthCredential> {
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
agentDir: params.agentDir,
profileId,
});
const store = ensureAuthProfileStore(ownerAgentDir, { allowKeychainPrompt: false });
const ownerCredential = store.profiles[profileId];
const credentialForOwner =
ownerCredential?.type === "oauth" && isCodexAppServerAuthProvider(ownerCredential.provider)
? ownerCredential
: credential;
if (params.forceRefresh) {
store.profiles[profileId] = { ...credential, expires: 0 };
saveAuthProfileStore(store, params.agentDir);
store.profiles[profileId] = { ...credentialForOwner, expires: 0 };
saveAuthProfileStore(store, ownerAgentDir);
}
const resolved = await resolveApiKeyForProfile({
store,
profileId,
agentDir: params.agentDir,
agentDir: ownerAgentDir,
});
const refreshed = loadAuthProfileStoreForSecretsRuntime(params.agentDir).profiles[profileId];
const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const storedCredential = store.profiles[profileId];
const candidate =
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider)

View File

@@ -80,30 +80,35 @@ function turnStartResult(turnId = "turn-auth-contract") {
function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "thread/resume" }) {
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: unknown) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: vi.fn(async (method: string, requestParams?: unknown) => {
requests.push({ method, params: requestParams });
if (method === params.startMethod) {
return threadStartResult();
}
if (method === "turn/start") {
return turnStartResult();
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
__testing.setCodexAppServerClientFactoryForTests(
async (_startOptions, authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
return {
request: vi.fn(async (method: string, requestParams?: unknown) => {
requests.push({ method, params: requestParams });
if (method === params.startMethod) {
return threadStartResult();
}
if (method === "turn/start") {
return turnStartResult();
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
},
);
return {
seenAuthProfileIds,
seenAgentDirs,
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), {
interval: 1,
@@ -140,6 +145,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
params.agentDir = tmpDir;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -149,6 +155,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
]),
{ interval: 1 },
);
expect(harness.seenAgentDirs).toEqual([tmpDir]);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;

View File

@@ -4,14 +4,16 @@ import type { CodexAppServerStartOptions } from "./config.js";
export type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
agentDir?: string,
) => Promise<CodexAppServerClient>;
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
) =>
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId }),
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir }),
);
export function createCodexAppServerClientFactoryTestHooks(

View File

@@ -125,6 +125,7 @@ async function compactCodexNativeThread(
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;

View File

@@ -355,6 +355,19 @@ describe("Codex app-server config", () => {
expect(second).not.toContain("sk-second");
});
it("derives distinct shared-client keys for distinct agent dirs", () => {
const startOptions = {
transport: "stdio" as const,
command: "codex",
args: ["app-server"],
headers: {},
};
expect(codexAppServerStartOptionsKey(startOptions, { agentDir: "/tmp/agent-a" })).not.toEqual(
codexAppServerStartOptionsKey(startOptions, { agentDir: "/tmp/agent-b" }),
);
});
it("keeps runtime config keys aligned with manifest schema and UI hints", async () => {
const manifest = JSON.parse(
await fs.readFile(new URL("../../openclaw.plugin.json", import.meta.url), "utf8"),

View File

@@ -294,7 +294,7 @@ export function resolveCodexComputerUseConfig(
export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: { authProfileId?: string } = {},
params: { authProfileId?: string; agentDir?: string } = {},
): string {
return JSON.stringify({
transport: options.transport,
@@ -311,6 +311,7 @@ export function codexAppServerStartOptionsKey(
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
authProfileId: params.authProfileId ?? null,
agentDir: params.agentDir ?? null,
});
}

View File

@@ -28,6 +28,7 @@ export type CodexAppServerListModelsOptions = {
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
agentDir?: string;
sharedClient?: boolean;
};
@@ -77,11 +78,13 @@ async function withCodexAppServerModelClient<T>(
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
});
try {
return await run({ client, timeoutMs });

View File

@@ -145,7 +145,9 @@ function assistantMessage(text: string, timestamp: number) {
function createAppServerHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown>,
options: { onStart?: (authProfileId: string | undefined) => void } = {},
options: {
onStart?: (authProfileId: string | undefined, agentDir: string | undefined) => void;
} = {},
) {
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
@@ -154,17 +156,19 @@ function createAppServerHarness(
return requestImpl(method, params);
});
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
options.onStart?.(authProfileId);
return {
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
__testing.setCodexAppServerClientFactoryForTests(
async (_startOptions, authProfileId, agentDir) => {
options.onStart?.(authProfileId, agentDir);
return {
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
},
);
return {
request,
@@ -202,7 +206,9 @@ function createAppServerHarness(
function createStartedThreadHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown> = async () => undefined,
options: { onStart?: (authProfileId: string | undefined) => void } = {},
options: {
onStart?: (authProfileId: string | undefined, agentDir: string | undefined) => void;
} = {},
) {
return createAppServerHarness(async (method, params) => {
const override = await requestImpl(method, params);
@@ -1300,14 +1306,19 @@ describe("runCodexAppServerAttempt", () => {
it("passes the selected auth profile into app-server startup", async () => {
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(undefined, {
onStart: (authProfileId) => seenAuthProfileIds.push(authProfileId),
onStart: (authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
},
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.authProfileId = "openai-codex:work";
params.agentDir = path.join(tempDir, "agent");
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"]), {
@@ -1319,6 +1330,7 @@ describe("runCodexAppServerAttempt", () => {
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:work"]);
expect(seenAgentDirs).toEqual([path.join(tempDir, "agent")]);
expect(requests.map((entry) => entry.method)).toContain("turn/start");
});
@@ -1622,6 +1634,7 @@ describe("runCodexAppServerAttempt", () => {
});
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
params.agentDir = path.join(tempDir, "agent");
const binding = await startOrResumeThread({
client: {
@@ -1660,6 +1673,7 @@ describe("runCodexAppServerAttempt", () => {
dynamicToolsFingerprint: "[]",
});
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const { requests, waitForMethod, completeTurn } = createAppServerHarness(
async (method: string) => {
if (method === "thread/resume") {
@@ -1670,10 +1684,16 @@ describe("runCodexAppServerAttempt", () => {
}
throw new Error(`unexpected method: ${method}`);
},
{ onStart: (authProfileId) => seenAuthProfileIds.push(authProfileId) },
{
onStart: (authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
},
},
);
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
params.agentDir = path.join(tempDir, "agent");
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:bound"]), {
@@ -1685,6 +1705,7 @@ describe("runCodexAppServerAttempt", () => {
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:bound"]);
expect(seenAgentDirs).toEqual([path.join(tempDir, "agent")]);
expect(requests.map((entry) => entry.method)).toContain("turn/start");
});
});

View File

@@ -311,7 +311,7 @@ export async function runCodexAppServerAttempt(
timeoutFloorMs: options.startupTimeoutFloorMs,
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory(appServer.start, startupAuthProfileId);
const startupClient = await clientFactory(appServer.start, startupAuthProfileId, agentDir);
await ensureCodexComputerUse({
client: startupClient,
pluginConfig: options.pluginConfig,

View File

@@ -145,6 +145,33 @@ describe("shared Codex app-server client", () => {
);
});
it("uses the selected agent dir for shared app-server auth bridging", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai-codex:work",
agentDir: "/tmp/openclaw-agent-nova",
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: "/tmp/openclaw-agent-nova",
authProfileId: "openai-codex:work",
}),
);
expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: "/tmp/openclaw-agent-nova",
authProfileId: "openai-codex:work",
}),
);
});
it("resolves the managed binary before bridging and spawning the shared client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -29,9 +29,10 @@ export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
agentDir?: string;
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const agentDir = resolveOpenClawAgentDir();
const agentDir = options?.agentDir ?? resolveOpenClawAgentDir();
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
@@ -42,6 +43,7 @@ export async function getSharedCodexAppServerClient(options?: {
});
const key = codexAppServerStartOptionsKey(startOptions, {
authProfileId: options?.authProfileId,
agentDir,
});
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
@@ -87,8 +89,9 @@ export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
agentDir?: string;
}): Promise<CodexAppServerClient> {
const agentDir = resolveOpenClawAgentDir();
const agentDir = options?.agentDir ?? resolveOpenClawAgentDir();
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);