fix: refresh Pi credential readiness

This commit is contained in:
2026-08-05 06:30:25 +02:00
parent 7df21b5f21
commit fd1fd2f802
5 changed files with 117 additions and 15 deletions
+1 -1
View File
@@ -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 {
+9 -2
View File
@@ -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";
+57 -2
View File
@@ -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: {
+48 -5
View File
@@ -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();
+2 -5
View File
@@ -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.") }),