Skip to content

Commit 567dba4

Browse files
committed
fix(coding-agent): team-scope and serialize the stale-auth entitlement preserve
Review follow-ups on the stale-preserve mechanism: - The keyless refresh restored entitlements even after the user selected a different prime-inference team, so wrong-team private models validated and then failed at the provider. The preserve now requires the current team selection to match the snapshot's team; a switch clears instead. - Concurrent refreshAvailableModels calls could snapshot the entitlement set between another call's clear (inside refresh()) and its restore, then restore that empty snapshot - losing the cached authorization permanently despite the preserve rule. Refresh sequences that snapshot and restore the sets now run serialized through one promise chain (getExecutableModels' standalone entitlement refresh included).
1 parent 9259840 commit 567dba4

2 files changed

Lines changed: 64 additions & 9 deletions

File tree

‎packages/coding-agent/src/core/model-registry.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -770,11 +770,27 @@ export class ModelRegistry {
770770
}
771771

772772
async refreshAvailableModels(): Promise<Model<Api>[]> {
773-
const previousPrivateModelIds = new Set(this.authorizedPrivatePrimeInferenceModelIds);
774-
const previousTeamId = this.authorizedPrivatePrimeInferenceTeamId;
775-
this.refresh();
776-
await this.refreshPrivatePrimeInferenceAuthorization(previousPrivateModelIds, previousTeamId);
777-
return this.getAvailable();
773+
// Serialized: a concurrent call snapshotting between this call's clear
774+
// (inside refresh()) and its restore would capture an empty entitlement
775+
// set and lose the cached private-model authorization for good.
776+
return this.runSerializedEntitlementRefresh(async () => {
777+
const previousPrivateModelIds = new Set(this.authorizedPrivatePrimeInferenceModelIds);
778+
const previousTeamId = this.authorizedPrivatePrimeInferenceTeamId;
779+
this.refresh();
780+
await this.refreshPrivatePrimeInferenceAuthorization(previousPrivateModelIds, previousTeamId);
781+
return this.getAvailable();
782+
});
783+
}
784+
785+
private entitlementRefreshChain: Promise<unknown> = Promise.resolve();
786+
787+
private runSerializedEntitlementRefresh<T>(task: () => Promise<T>): Promise<T> {
788+
const run = this.entitlementRefreshChain.then(task, task);
789+
this.entitlementRefreshChain = run.then(
790+
() => undefined,
791+
() => undefined,
792+
);
793+
return run;
778794
}
779795

780796
private async refreshPrivatePrimeInferenceAuthorization(
@@ -786,8 +802,12 @@ export class ModelRegistry {
786802
const teamId = teamHeaders?.["X-Prime-Team-ID"];
787803
if (!apiKey || !teamHeaders || !teamId) {
788804
// Stale is not logout: keep fetched entitlements for explicit re-selection
789-
// (the auth filter still hides the models while stale).
790-
if (this.authStorage.getAuthStatus(PRIME_INFERENCE_PROVIDER_ID).source === "stale") {
805+
// (the auth filter still hides the models while stale) — but only for
806+
// the team they were fetched for; a team switch invalidates them.
807+
if (
808+
this.authStorage.getAuthStatus(PRIME_INFERENCE_PROVIDER_ID).source === "stale" &&
809+
teamId === previousTeamId
810+
) {
791811
this.authorizedPrivatePrimeInferenceModelIds = previousPrivateModelIds;
792812
this.authorizedPrivatePrimeInferenceTeamId = previousTeamId;
793813
return;
@@ -976,7 +996,7 @@ export class ModelRegistry {
976996
}
977997

978998
async getExecutableModels(): Promise<Model<Api>[]> {
979-
await this.refreshPrivatePrimeInferenceAuthorization();
999+
await this.runSerializedEntitlementRefresh(() => this.refreshPrivatePrimeInferenceAuthorization());
9801000
const availableModels = this.getAvailable();
9811001
const codexModels = availableModels.filter((model) => model.provider === "openai-codex");
9821002
if (codexModels.length === 0) {

‎packages/coding-agent/test/model-registry.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from "node:path";
44
import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
55
import { getApiProvider } from "@earendil-works/pi-ai";
66
import { getOAuthProvider, registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
7-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
7+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
88
import { AuthStorage } from "../src/core/auth-storage.js";
99
import { ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.js";
1010

@@ -1502,6 +1502,41 @@ describe("ModelRegistry", () => {
15021502
await expect(registry.getApiKeyForProvider("custom-provider")).resolves.toBe("literal_api_key_value");
15031503
});
15041504

1505+
test("preserves stale-auth entitlements only for the same prime-inference team", async () => {
1506+
authStorage.setRuntimeApiKey("prime-inference", "prime-key");
1507+
const headerSpy = vi
1508+
.spyOn(authStorage, "getProviderHeaders")
1509+
.mockReturnValue({ "X-Prime-Team-ID": "team-a" });
1510+
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
1511+
const internals = registry as unknown as {
1512+
authorizedPrivatePrimeInferenceModelIds: Set<string>;
1513+
authorizedPrivatePrimeInferenceTeamId: string | undefined;
1514+
};
1515+
internals.authorizedPrivatePrimeInferenceModelIds.add("internal/private-model");
1516+
internals.authorizedPrivatePrimeInferenceTeamId = "team-a";
1517+
expect(registry.markProviderAuthStale("prime-inference")).toBe(true);
1518+
1519+
await registry.refreshAvailableModels();
1520+
expect(internals.authorizedPrivatePrimeInferenceModelIds.has("internal/private-model")).toBe(true);
1521+
1522+
// A team switch invalidates entitlements fetched for the old team.
1523+
headerSpy.mockReturnValue({ "X-Prime-Team-ID": "team-b" });
1524+
await registry.refreshAvailableModels();
1525+
expect(internals.authorizedPrivatePrimeInferenceModelIds.size).toBe(0);
1526+
});
1527+
1528+
test("concurrent stale-auth refreshes do not drop preserved entitlements", async () => {
1529+
authStorage.setRuntimeApiKey("prime-inference", "prime-key");
1530+
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
1531+
const internals = registry as unknown as { authorizedPrivatePrimeInferenceModelIds: Set<string> };
1532+
internals.authorizedPrivatePrimeInferenceModelIds.add("internal/private-model");
1533+
expect(registry.markProviderAuthStale("prime-inference")).toBe(true);
1534+
1535+
await Promise.all([registry.refreshAvailableModels(), registry.refreshAvailableModels()]);
1536+
1537+
expect(internals.authorizedPrivatePrimeInferenceModelIds.has("internal/private-model")).toBe(true);
1538+
});
1539+
15051540
test("provider auth status reports command apiKey values from models.json without executing them", () => {
15061541
const counterFile = join(tempDir, "status-counter");
15071542
writeFileSync(counterFile, "0");

0 commit comments

Comments
 (0)