Skip to content

Commit e40d8cc

Browse files
committed
fix(agents): refresh Codex OAuth credentials on profile rotation
1 parent 349ee4a commit e40d8cc

2 files changed

Lines changed: 171 additions & 6 deletions

File tree

src/agents/pi-embedded-runner/run.overflow-compaction.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,171 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
624624
});
625625
});
626626

627+
it("refreshes bootstrapped Codex OAuth credentials when rotating profiles", async () => {
628+
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
629+
const subscriptionLimit = new Error(
630+
"You've reached your Codex subscription usage limit. Next reset in 20 hours.",
631+
);
632+
const normalizedLimit = Object.assign(new Error(subscriptionLimit.message), {
633+
name: "FailoverError",
634+
reason: "rate_limit",
635+
status: 429,
636+
});
637+
let attemptCount = 0;
638+
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => {
639+
attemptCount += 1;
640+
return attemptCount === 1
641+
? makeAttemptResult({ promptError: subscriptionLimit })
642+
: makeAttemptResult({ assistantTexts: ["backup ok"], promptError: null });
643+
});
644+
const codexAuthStorage = {
645+
setRuntimeApiKey: vi.fn(),
646+
getApiKey: vi.fn(async () => "stored-test-key"),
647+
};
648+
const firstRuntimePlan = makeForwardedRuntimePlan({
649+
resolvedRef: {
650+
provider: "openai-codex",
651+
modelId: "gpt-5.5",
652+
harnessId: "codex",
653+
},
654+
auth: {
655+
providerForAuth: "openai-codex",
656+
authProfileProviderForAuth: "openai-codex",
657+
harnessAuthProvider: "openai-codex",
658+
forwardedAuthProfileId: "openai-codex:sub",
659+
forwardedAuthProfileCandidateIds: ["openai-codex:sub", "openai-codex:backup"],
660+
},
661+
});
662+
const secondRuntimePlan = makeForwardedRuntimePlan({
663+
resolvedRef: {
664+
provider: "openai-codex",
665+
modelId: "gpt-5.5",
666+
harnessId: "codex",
667+
},
668+
auth: {
669+
providerForAuth: "openai-codex",
670+
authProfileProviderForAuth: "openai-codex",
671+
harnessAuthProvider: "openai-codex",
672+
forwardedAuthProfileId: "openai-codex:backup",
673+
forwardedAuthProfileCandidateIds: ["openai-codex:sub", "openai-codex:backup"],
674+
},
675+
});
676+
const codexAuthStore = {
677+
version: 1 as const,
678+
profiles: {
679+
"openai-codex:sub": {
680+
type: "oauth" as const,
681+
provider: "openai-codex",
682+
access: "sub-access-token",
683+
refresh: "sub-refresh-token",
684+
expires: Date.now() + 60_000,
685+
},
686+
"openai-codex:backup": {
687+
type: "oauth" as const,
688+
provider: "openai-codex",
689+
access: "backup-access-token",
690+
refresh: "backup-refresh-token",
691+
expires: Date.now() + 60_000,
692+
},
693+
},
694+
};
695+
clearAgentHarnesses();
696+
registerAgentHarness({
697+
id: "codex",
698+
label: "Codex",
699+
supports: () => ({ supported: false }),
700+
runAttempt: pluginRunAttempt,
701+
});
702+
mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore);
703+
mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai-codex:sub", "openai-codex:backup"]);
704+
mockedResolveModelAsync
705+
.mockResolvedValueOnce({
706+
model: {
707+
id: "gpt-5.5",
708+
provider: "openai",
709+
contextWindow: 200000,
710+
api: "openai-responses",
711+
},
712+
error: null,
713+
authStorage: { setRuntimeApiKey: vi.fn() },
714+
modelRegistry: {},
715+
})
716+
.mockResolvedValueOnce({
717+
model: {
718+
id: "gpt-5.5",
719+
provider: "openai-codex",
720+
contextWindow: 200000,
721+
api: "openai-codex-responses",
722+
},
723+
error: null,
724+
authStorage: codexAuthStorage,
725+
modelRegistry: {},
726+
});
727+
mockedBuildAgentRuntimePlan
728+
.mockReturnValueOnce(firstRuntimePlan)
729+
.mockReturnValueOnce(secondRuntimePlan);
730+
mockedGetApiKeyForModel.mockImplementation(
731+
async ({ profileId }: { profileId?: string } = {}) => ({
732+
apiKey: profileId === "openai-codex:backup" ? "backup-token" : "sub-token",
733+
profileId: profileId ?? "openai-codex:sub",
734+
source: "test",
735+
mode: "api-key",
736+
}),
737+
);
738+
mockedCoerceToFailoverError.mockReturnValueOnce(normalizedLimit);
739+
mockedDescribeFailoverError.mockImplementation((err: unknown) => ({
740+
message: err instanceof Error ? err.message : String(err),
741+
reason: err === normalizedLimit ? "rate_limit" : undefined,
742+
status: err === normalizedLimit ? 429 : undefined,
743+
code: undefined,
744+
}));
745+
746+
try {
747+
await runEmbeddedPiAgent({
748+
...overflowBaseRunParams,
749+
provider: "openai",
750+
model: "gpt-5.5",
751+
config: {
752+
agents: {
753+
defaults: {
754+
agentRuntime: { id: "codex" },
755+
},
756+
},
757+
},
758+
runId: "forced-openai-codex-responses-rotates-oauth",
759+
});
760+
} finally {
761+
clearAgentHarnesses();
762+
}
763+
764+
expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(2);
765+
expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenNthCalledWith(
766+
1,
767+
"openai-codex",
768+
"sub-token",
769+
);
770+
expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenNthCalledWith(
771+
2,
772+
"openai-codex",
773+
"backup-token",
774+
);
775+
expect(pluginRunAttempt).toHaveBeenCalledTimes(2);
776+
expectMockCallFields(pluginRunAttempt, {
777+
provider: "openai-codex",
778+
authProfileId: "openai-codex:sub",
779+
resolvedApiKey: "sub-token",
780+
});
781+
expectMockCallFields(
782+
pluginRunAttempt,
783+
{
784+
provider: "openai-codex",
785+
authProfileId: "openai-codex:backup",
786+
resolvedApiKey: "backup-token",
787+
},
788+
1,
789+
);
790+
});
791+
627792
it("keeps auto-selected OpenAI Codex auth profiles for forced codex harness runs", async () => {
628793
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
629794
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>

src/agents/pi-embedded-runner/run.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,10 @@ export async function runEmbeddedPiAgent(
937937
}
938938
return false;
939939
};
940+
const advanceAttemptAuthProfile =
941+
pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap
942+
? advancePluginHarnessAuthProfile
943+
: advanceAuthProfile;
940944

941945
// Plugin harnesses own their model transport/auth. Running PI's generic
942946
// auth bootstrap here can turn synthetic provider markers into real
@@ -2264,9 +2268,7 @@ export async function runEmbeddedPiAgent(
22642268
});
22652269
if (
22662270
promptFailoverDecision.action === "rotate_profile" &&
2267-
(await (pluginHarnessOwnsTransport
2268-
? advancePluginHarnessAuthProfile()
2269-
: advanceAuthProfile()))
2271+
(await advanceAttemptAuthProfile())
22702272
) {
22712273
if (failedPromptProfileId && promptProfileFailureReason) {
22722274
void maybeMarkAuthProfileFailure({
@@ -2496,9 +2498,7 @@ export async function runEmbeddedPiAgent(
24962498
maybeMarkAuthProfileFailure,
24972499
maybeEscalateRateLimitProfileFallback,
24982500
maybeBackoffBeforeOverloadFailover,
2499-
advanceAuthProfile: pluginHarnessOwnsTransport
2500-
? advancePluginHarnessAuthProfile
2501-
: advanceAuthProfile,
2501+
advanceAuthProfile: advanceAttemptAuthProfile,
25022502
});
25032503
overloadProfileRotations = assistantFailoverOutcome.overloadProfileRotations;
25042504
if (assistantFailoverOutcome.action === "retry") {

0 commit comments

Comments
 (0)