fix: refresh Pi credential readiness
This commit is contained in:
@@ -117,7 +117,7 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
|||||||
return piProviderCredentialStatus({
|
return piProviderCredentialStatus({
|
||||||
provider,
|
provider,
|
||||||
authProviders: loadPiAuthProviders(),
|
authProviders: loadPiAuthProviders(),
|
||||||
credentialValue: secretValue(config, "THT_MODEL_API_KEY"),
|
resolveCredentialValue: () => secretValue(config, "THT_MODEL_API_KEY"),
|
||||||
credentialFile: config.modelApiKeyFile,
|
credentialFile: config.modelApiKeyFile,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function buildPiChildEnv(opts: {
|
|||||||
export function piProviderCredentialStatus(opts: {
|
export function piProviderCredentialStatus(opts: {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
credentialFile?: string;
|
credentialFile?: string;
|
||||||
credentialValue?: string;
|
resolveCredentialValue?: () => string | undefined;
|
||||||
authProviders?: ReadonlySet<string>;
|
authProviders?: ReadonlySet<string>;
|
||||||
fsOps?: CredentialFsOps;
|
fsOps?: CredentialFsOps;
|
||||||
}): PiCredentialStatus {
|
}): PiCredentialStatus {
|
||||||
@@ -187,7 +187,14 @@ export function piProviderCredentialStatus(opts: {
|
|||||||
if (!provider || LOCAL_PROVIDERS.has(provider)) return "missing";
|
if (!provider || LOCAL_PROVIDERS.has(provider)) return "missing";
|
||||||
if (opts.authProviders?.has(provider)) return "present";
|
if (opts.authProviders?.has(provider)) return "present";
|
||||||
try {
|
try {
|
||||||
buildPiChildEnv({ ...opts, ambient: {} });
|
buildPiChildEnv({
|
||||||
|
ambient: {},
|
||||||
|
provider,
|
||||||
|
credentialFile: opts.credentialFile,
|
||||||
|
credentialValue: opts.resolveCredentialValue?.(),
|
||||||
|
authProviders: opts.authProviders,
|
||||||
|
fsOps: opts.fsOps,
|
||||||
|
});
|
||||||
return "present";
|
return "present";
|
||||||
} catch {
|
} catch {
|
||||||
return "missing";
|
return "missing";
|
||||||
|
|||||||
@@ -135,15 +135,70 @@ test("credential status reports only present or missing without treating local p
|
|||||||
expect(piProviderCredentialStatus({
|
expect(piProviderCredentialStatus({
|
||||||
provider: "deepseek",
|
provider: "deepseek",
|
||||||
authProviders: new Set(["deepseek"]),
|
authProviders: new Set(["deepseek"]),
|
||||||
credentialValue: "must-not-be-returned",
|
resolveCredentialValue: () => "must-not-be-returned",
|
||||||
})).toBe("present");
|
})).toBe("present");
|
||||||
expect(piProviderCredentialStatus({ provider: "deepseek" })).toBe("missing");
|
expect(piProviderCredentialStatus({ provider: "deepseek" })).toBe("missing");
|
||||||
expect(piProviderCredentialStatus({
|
expect(piProviderCredentialStatus({
|
||||||
provider: "local-qwen",
|
provider: "local-qwen",
|
||||||
credentialValue: "must-not-be-returned",
|
resolveCredentialValue: () => "must-not-be-returned",
|
||||||
})).toBe("missing");
|
})).toBe("missing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Catches the generic managed secret being read before providers that self-authenticate or need
|
||||||
|
// no credential have been classified.
|
||||||
|
test("credential status never resolves the generic secret for auth-store or local providers", () => {
|
||||||
|
let secretReads = 0;
|
||||||
|
const unreadableSecret = () => {
|
||||||
|
secretReads += 1;
|
||||||
|
throw new Error("unrelated generic secret is unreadable");
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(piProviderCredentialStatus({
|
||||||
|
provider: "deepseek",
|
||||||
|
authProviders: new Set(["deepseek"]),
|
||||||
|
resolveCredentialValue: unreadableSecret,
|
||||||
|
})).toBe("present");
|
||||||
|
expect(piProviderCredentialStatus({
|
||||||
|
provider: "local-qwen",
|
||||||
|
resolveCredentialValue: unreadableSecret,
|
||||||
|
})).toBe("missing");
|
||||||
|
expect(secretReads).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catches generic hosted providers skipping their managed-secret source or treating an absent or
|
||||||
|
// unreadable source as credentialed.
|
||||||
|
test("credential status resolves the generic secret only for providers that require it", () => {
|
||||||
|
let presentReads = 0;
|
||||||
|
expect(piProviderCredentialStatus({
|
||||||
|
provider: "openai",
|
||||||
|
resolveCredentialValue: () => {
|
||||||
|
presentReads += 1;
|
||||||
|
return "managed-openai-key";
|
||||||
|
},
|
||||||
|
})).toBe("present");
|
||||||
|
expect(presentReads).toBe(1);
|
||||||
|
|
||||||
|
let missingReads = 0;
|
||||||
|
expect(piProviderCredentialStatus({
|
||||||
|
provider: "openai",
|
||||||
|
resolveCredentialValue: () => {
|
||||||
|
missingReads += 1;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
})).toBe("missing");
|
||||||
|
expect(missingReads).toBe(1);
|
||||||
|
|
||||||
|
let unreadableReads = 0;
|
||||||
|
expect(piProviderCredentialStatus({
|
||||||
|
provider: "openai",
|
||||||
|
resolveCredentialValue: () => {
|
||||||
|
unreadableReads += 1;
|
||||||
|
throw new Error("generic secret is unreadable");
|
||||||
|
},
|
||||||
|
})).toBe("missing");
|
||||||
|
expect(unreadableReads).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("bundle value is injected without exposing bundle metadata to Pi", () => {
|
test("bundle value is injected without exposing bundle metadata to Pi", () => {
|
||||||
const env = buildPiChildEnv({
|
const env = buildPiChildEnv({
|
||||||
ambient: {
|
ambient: {
|
||||||
|
|||||||
@@ -81,6 +81,38 @@ test("saves only a selected non-secret configuration", async () => {
|
|||||||
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved");
|
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Catches a provider switch updating only the saved config while leaving the credential rail
|
||||||
|
// attached to the previously selected provider.
|
||||||
|
test("shows authoritative credential presence after saving a different provider", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let saved = false;
|
||||||
|
server.use(
|
||||||
|
http.get("/api/pi-management/status", () => HttpResponse.json(saved ? {
|
||||||
|
...readyStatus,
|
||||||
|
credentials: "missing",
|
||||||
|
config: { provider: "deepseek", model: "deepseek-v4", reasoning: "medium" },
|
||||||
|
checkedAt: "2026-08-05T10:02:00.000Z",
|
||||||
|
} : readyStatus)),
|
||||||
|
http.put("/api/pi-management/config", async ({ request }) => {
|
||||||
|
saved = true;
|
||||||
|
return HttpResponse.json({
|
||||||
|
...await request.json() as object,
|
||||||
|
updatedAt: "2026-08-05T10:01:00.000Z",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderManagement();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Credentials present")).toBeVisible();
|
||||||
|
await user.selectOptions(screen.getByLabelText("Provider"), "deepseek");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save defaults" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Credentials missing")).toBeVisible();
|
||||||
|
expect(screen.queryByText("Credentials present")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Provider")).toHaveValue("deepseek");
|
||||||
|
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved");
|
||||||
|
});
|
||||||
|
|
||||||
test("runs the saved-configuration test without changing credential presence", async () => {
|
test("runs the saved-configuration test without changing credential presence", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let tests = 0;
|
let tests = 0;
|
||||||
@@ -196,17 +228,28 @@ test("shows an explicit recoverable incomplete state when no provider model is a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps suggested draft choices distinct from invalid persisted defaults until save succeeds", async () => {
|
test("keeps suggested draft choices distinct from invalid persisted defaults until save succeeds", async () => {
|
||||||
|
let configured = false;
|
||||||
const persisted = {
|
const persisted = {
|
||||||
...readyStatus,
|
...readyStatus,
|
||||||
credentials: "missing",
|
credentials: "missing",
|
||||||
config: { provider: "retired", model: "old-model", reasoning: "medium" },
|
config: { provider: "retired", model: "old-model", reasoning: "medium" },
|
||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("/api/pi-management/status", () => HttpResponse.json(persisted)),
|
http.get("/api/pi-management/status", () => HttpResponse.json(configured ? {
|
||||||
http.put("/api/pi-management/config", async ({ request }) => HttpResponse.json({
|
...readyStatus,
|
||||||
...await request.json() as object,
|
credentials: "missing",
|
||||||
updatedAt: "2026-08-05T10:05:00.000Z",
|
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||||
})),
|
checkedAt: "2026-08-05T10:05:00.000Z",
|
||||||
|
} : persisted)),
|
||||||
|
http.put("/api/pi-management/config", () => {
|
||||||
|
configured = true;
|
||||||
|
return HttpResponse.json({
|
||||||
|
provider: "zai",
|
||||||
|
model: "glm-5.2",
|
||||||
|
reasoning: "medium",
|
||||||
|
updatedAt: "2026-08-05T10:05:00.000Z",
|
||||||
|
});
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderManagement();
|
renderManagement();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
savePiManagementConfig,
|
savePiManagementConfig,
|
||||||
type PiInstallationConfig,
|
type PiInstallationConfig,
|
||||||
type PiManagementOptions,
|
type PiManagementOptions,
|
||||||
type PiManagementStatus,
|
|
||||||
} from "../api/pi-management";
|
} from "../api/pi-management";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||||
@@ -143,13 +142,11 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: savePiManagementConfig,
|
mutationFn: savePiManagementConfig,
|
||||||
onSuccess: (saved) => {
|
onSuccess: async (saved) => {
|
||||||
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
|
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
|
||||||
queryClient.setQueryData<PiManagementStatus>(["pi-management", "status"], (current) => (
|
|
||||||
current ? { ...current, config } : current
|
|
||||||
));
|
|
||||||
setDraft(config);
|
setDraft(config);
|
||||||
setSmokeState(undefined);
|
setSmokeState(undefined);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] });
|
||||||
setFeedback({ tone: "success", message: "Defaults saved." });
|
setFeedback({ tone: "success", message: "Defaults saved." });
|
||||||
},
|
},
|
||||||
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
|
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
|
||||||
|
|||||||
Reference in New Issue
Block a user