Q169: saved cloud and custom endpoints refill their form and keep the saved key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 04:02:08 -04:00
1 parent 5cd413ffcf
commit a8fbc08df6
13 files changed
+160 -23

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ export const platformContracts = {
setIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES), value: z.string() }) satisfies z.ZodType<C.SetIntegrationKeyInput>,
clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType<C.ClearIntegrationKeyInput>,
saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType<C.SaveProviderCredentialInput>,
providerEndpoint: z.strictObject({ orgId: id, providerId: id }),
setProviderModel: z.strictObject({ orgId: id, providerId: id, model: z.string().max(200) }) satisfies z.ZodType<C.SetProviderModelInput>,
revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType<C.RevokeProviderCredentialInput>,
startProviderOAuth: z.strictObject({
+7
View File
@@ -226,6 +226,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
handle("deleteMessage", (input) => mutate(() => chat.deleteMessage(input)));
handle("markChatSpaceRead", (input) => mutate(() => chat.markSpaceRead(input)));
handle("saveProviderCredential", (input) => mutate(() => providers.saveCredential(input)));
handle("providerEndpoint", (input) => {
try {
return ok(providers.endpoint(input.orgId, input.providerId));
} catch (error) {
return fail("provider_endpoint_failed", error instanceof Error ? error.message : String(error));
}
});
handle("setProviderModel", (input) => mutate(() => setProviderModel(deps.connection.db, input)));
handle("revokeProviderCredential", async (input) => {
try {
+29
View File
@@ -0,0 +1,29 @@
import type { DatabaseSync } from "node:sqlite";
import type { ProviderEndpoint } from "../../shared/commands";
import type { AuthBrokerService } from "./auth-broker-service";
import { loadCredential, type ProviderRow } from "./provider-internal";
function savedPayload(db: DatabaseSync, authBroker: AuthBrokerService, orgId: string, providerId: string): Record<string, unknown> | null {
const provider = db.prepare("select * from providers where id = ? and org_id = ?").get(providerId, orgId) as ProviderRow | undefined;
if (!provider) throw new Error("Provider was not found.");
if (provider.auth_type !== "cloud" || !provider.credential_id) return null;
return JSON.parse(loadCredential(authBroker, orgId, provider).secret) as Record<string, unknown>;
}
const text = (value: unknown): string => (typeof value === "string" ? value : "");
/** The saved address, region, project and deployment of a cloud or custom provider, so its form shows what is in use; never the key. */
export function providerEndpoint(db: DatabaseSync, authBroker: AuthBrokerService, orgId: string, providerId: string): ProviderEndpoint | null {
const saved = savedPayload(db, authBroker, orgId, providerId);
return saved ? { baseUrl: text(saved.baseUrl), region: text(saved.region), projectId: text(saved.projectId), deployment: text(saved.deployment) } : null;
}
/** A cloud form that left its key blank asks to keep the saved key; the saved key goes into the new payload. */
export function withSavedSecret(db: DatabaseSync, authBroker: AuthBrokerService, orgId: string, providerId: string, secret: string): string {
const payload = JSON.parse(secret) as Record<string, unknown>;
if (payload.keepSavedSecret !== true) return secret;
const saved = savedPayload(db, authBroker, orgId, providerId);
if (!saved) throw new Error("There is no saved key to keep; enter the key.");
delete payload.keepSavedSecret;
return JSON.stringify({ ...payload, secret: saved.secret });
}
+9 -3
View File
@@ -1,5 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import type { CancelProviderResponseInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands";
import type { CancelProviderResponseInput, ListProviderModelsInput, ProviderEndpoint, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands";
import { providerEndpoint, withSavedSecret } from "./provider-endpoint";
import type { ProviderModelEntry } from "../../shared/provider-models";
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
@@ -97,8 +98,9 @@ export class ProviderService {
const provider = this.db.prepare("select * from providers where id = ? and org_id = ?")
.get(input.providerId, input.orgId) as { provider_key: string; auth_type: ProviderAuthType } | undefined;
if (!provider) throw new Error("Provider was not found.");
const secret = input.secret.trim();
if (!secret) throw new Error("Credential is required.");
const typed = input.secret.trim();
if (!typed) throw new Error("Credential is required.");
const secret = provider.auth_type === "cloud" ? withSavedSecret(this.db, this.authBroker, input.orgId, input.providerId, typed) : typed;
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
adapter.parseAuthProfile(secret);
const catalogEntry = requireCatalogEntry(provider.provider_key);
@@ -130,6 +132,10 @@ export class ProviderService {
});
}
endpoint(orgId: string, providerId: string): ProviderEndpoint | null {
return providerEndpoint(this.db, this.authBroker, orgId, providerId);
}
/** Revokes at the provider where it can, then always disconnects and erases the credential here; a remote failure is reported, not hidden. */
async revokeCredential(input: RevokeProviderCredentialInput): Promise<void> {
const provider = this.db.prepare(`
+1
View File
@@ -55,6 +55,7 @@ const api: DesktopApi = {
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
setProviderModel: (input) => invoke(IPC_CHANNELS.setProviderModel, input),
providerEndpoint: (input) => invoke(IPC_CHANNELS.providerEndpoint, input),
revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input),
startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input),
cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input),
+3
View File
@@ -680,6 +680,9 @@ export const zh: Record<string, string> = {
"{percent}% of cap": "上限的 {percent}%",
"Default model for {label}": "{label} 的默认模型",
"Change model": "更换模型",
"Saved. Leave blank to keep it.": "已保存。留空即保留。",
"The saved settings could not be read: {error}": "无法读取已保存的设置:{error}",
"Loading saved settings…": "正在读取已保存的设置…",
"Leave the model blank to use the provider's default. A changed model applies from the agent's next session.": "模型留空即使用 Provider 的默认模型。修改后的模型从该 Agent 的下一次会话起生效。",
"{model} (provider default)": "{model}(Provider 默认)",
"Chosen when the session starts": "会话开始时确定",
@@ -1,18 +1,38 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ProviderCatalogEntry, ProviderRecord } from "../../../../shared/domain";
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
import { Field } from "../../shared/ui";
import { t } from "../../i18n/i18n";
import type { ModelCheckResult } from "../../../../shared/desktop-api";
import type { ProviderEndpoint } from "../../../../shared/commands";
interface CloudFields {
baseUrl: string;
region: string;
projectId: string;
deployment: string;
const CLOUD_DEFAULTS: ProviderEndpoint = { baseUrl: "", region: "", projectId: "", deployment: "" };
/** The saved endpoint of a connected cloud or custom provider: undefined while loading, null when nothing is saved. */
function useSavedEndpoint(provider: ProviderRecord, entry: ProviderCatalogEntry): { endpoint: ProviderEndpoint | null | undefined; error: string | null } {
const saved = entry.authType === "cloud" && Boolean(provider.credentialId);
const [state, setState] = useState<{ endpoint: ProviderEndpoint | null | undefined; error: string | null }>({ endpoint: saved ? undefined : null, error: null });
useEffect(() => {
if (!saved) return;
let live = true;
void window.agentCompany.providerEndpoint({ orgId: provider.orgId, providerId: provider.id }).then((result) => {
if (!live) return;
setState(result.ok ? { endpoint: result.data, error: null } : { endpoint: null, error: result.error.message });
});
return () => { live = false; };
}, [saved, provider.orgId, provider.id]);
return state;
}
const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" };
/** With a saved key the field may stay blank, which keeps it. */
function SecretField({ label, saved, required }: { label: string; saved: boolean; required: boolean }) {
return (
<Field label={label}>
<input className="input" name="secret" type="password" required={required && !saved} placeholder={saved ? t("Saved. Leave blank to keep it.") : undefined} autoFocus />
{saved && <input type="hidden" name="keepSavedSecret" value="1" />}
</Field>
);
}
export function ProviderConnectFields({
provider,
@@ -30,10 +50,16 @@ export function ProviderConnectFields({
</>
);
}
if (entry.customEndpoint) {
return <CompatFieldsForm entry={entry} provider={provider} />;
}
return <CloudFieldsForm entry={entry} provider={provider} />;
return <SavedEndpointForm entry={entry} provider={provider} />;
}
function SavedEndpointForm({ provider, entry }: { provider: ProviderRecord; entry: ProviderCatalogEntry }) {
const { endpoint, error } = useSavedEndpoint(provider, entry);
if (error) return <div className="form-error" role="alert">{t("The saved settings could not be read: {error}", { error })}</div>;
if (endpoint === undefined) return <p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>{t("Loading saved settings…")}</p>;
return entry.customEndpoint
? <CompatFieldsForm entry={entry} provider={provider} saved={endpoint} />
: <CloudFieldsForm entry={entry} provider={provider} saved={endpoint} />;
}
export function ProviderOAuthFields({
@@ -75,8 +101,8 @@ export function ProviderOAuthFields({
);
}
function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
const [baseUrl, setBaseUrl] = useState("");
function CompatFieldsForm({ entry, provider, saved }: { entry: ProviderCatalogEntry; provider: ProviderRecord; saved: ProviderEndpoint | null }) {
const [baseUrl, setBaseUrl] = useState(saved?.baseUrl ?? "");
return (
<>
<p style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
@@ -88,7 +114,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr
onChange={(event) => setBaseUrl(event.target.value)}
/>
</Field>
<Field label={t("API key (if the server asks for one)")}><input className="input" name="secret" type="password" /></Field>
<SecretField label={t("API key (if the server asks for one)")} saved={saved !== null} required={false} />
<ModelPicker entry={entry} provider={provider} />
</>
);
@@ -159,9 +185,9 @@ export function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry;
);
}
function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
const [state, setState] = useState<CloudFields>(CLOUD_DEFAULTS);
const update = (patch: Partial<CloudFields>) => setState((current) => ({ ...current, ...patch }));
function CloudFieldsForm({ entry, provider, saved }: { entry: ProviderCatalogEntry; provider: ProviderRecord; saved: ProviderEndpoint | null }) {
const [state, setState] = useState<ProviderEndpoint>(saved ?? CLOUD_DEFAULTS);
const update = (patch: Partial<ProviderEndpoint>) => setState((current) => ({ ...current, ...patch }));
return (
<>
<p style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
@@ -177,9 +203,7 @@ function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pro
<Field label={t("Deployment / model id")}>
<input className="input" name="cloudDeployment" value={state.deployment} onChange={(event) => update({ deployment: event.target.value })} />
</Field>
<Field label={t("Credential")}>
<input className="input" name="secret" type="password" required autoFocus />
</Field>
<SecretField label={t("Credential")} saved={saved !== null} required />
<Field label={t("Default model")}>
<input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} />
</Field>
@@ -9,6 +9,7 @@ export function composeSecret(entry: ProviderCatalogEntry, form: FormData): stri
projectId: String(form.get("cloudProject") ?? "").trim(),
deployment: String(form.get("cloudDeployment") ?? "").trim(),
secret: raw,
...(raw === "" && form.get("keepSavedSecret") === "1" ? { keepSavedSecret: true } : {}),
};
return JSON.stringify(payload);
}
+8
View File
@@ -7,6 +7,14 @@ export interface SaveProviderCredentialInput {
model: string | null;
}
/** The non-secret part of a saved cloud or custom-endpoint credential. */
export interface ProviderEndpoint {
baseUrl: string;
region: string;
projectId: string;
deployment: string;
}
export interface SetProviderModelInput {
orgId: string;
providerId: string;
+3
View File
@@ -73,6 +73,7 @@ import type {
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
SetProviderModelInput,
ProviderEndpoint,
ConnectComposioToolkitInput,
ConnectComposioToolkitResult,
ConnectorCatalogEntry,
@@ -165,6 +166,8 @@ export interface DesktopApi {
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
setProviderModel(input: SetProviderModelInput): Promise<DesktopResult<AppSnapshot>>;
/** Null when the provider has no saved cloud or custom-endpoint credential. */
providerEndpoint(input: { orgId: string; providerId: string }): Promise<DesktopResult<ProviderEndpoint | null>>;
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
+1
View File
@@ -28,6 +28,7 @@ export const IPC_CHANNELS = {
markChatSpaceRead: "agentcompany:mark-chat-space-read",
saveProviderCredential: "agentcompany:save-provider-credential",
setProviderModel: "agentcompany:set-provider-model",
providerEndpoint: "agentcompany:provider-endpoint",
revokeProviderCredential: "agentcompany:revoke-provider-credential",
startProviderOAuth: "agentcompany:start-provider-oauth",
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
+16
View File
@@ -212,6 +212,22 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await changeModel(() => "gpt-5.4");
await expect(page.getByText("gpt-5.4 · API key").first()).toBeVisible();
// A saved custom endpoint reopens with its address filled in, and a blank key keeps the saved one.
await page.locator("div", { hasText: /^Custom OpenAI-compatible$/ }).locator("xpath=../..").getByRole("button", { name: "Connect" }).click();
await page.locator(".modal input[name=cloudBaseUrl]").fill("http://localhost:11434/v1");
await page.locator(".modal input[name=secret]").fill("local-key");
await page.locator(".modal [name=model]").fill("llama3");
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
const customRow = page.locator(".reveal-row", { hasText: "Custom OpenAI-compatible" });
await customRow.getByRole("button", { name: "Manage" }).click();
await expect(page.locator(".modal input[name=cloudBaseUrl]")).toHaveValue("http://localhost:11434/v1");
await expect(page.locator(".modal input[name=secret]")).toHaveAttribute("placeholder", "Saved. Leave blank to keep it.");
await page.locator(".modal [name=model]").fill("llama3.1");
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
await expect(customRow.getByText("llama3.1 · Cloud")).toBeVisible();
await customRow.getByRole("button", { name: "Disconnect" }).click();
await expect(customRow).toHaveCount(0);
// Skills are built-in now: no Skills tab exists, yet the bundled
// catalogue imported itself during onboarding.
await expect(page.getByRole("button", { name: "Skills" })).toHaveCount(0);
+37
View File
@@ -0,0 +1,37 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { ProviderService } from "../../src/main/services/provider-service";
import { providerAdapters } from "../../src/main/services/provider-adapters";
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
import { loadCredential, type ProviderRow } from "../../src/main/services/provider-internal";
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
describe("a saved cloud endpoint", () => {
test("refills its form without the key, and a blank key keeps the saved one", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-endpoint-${randomUUID()}.db`));
const db = connection.db;
const providers = new ProviderService(db, providerAdapters, vault);
let orgId = "";
connection.transaction(() => {
orgId = new OrganizationService(db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
});
const azure = (db.prepare("select id from providers where org_id = ? and provider_key = 'azure-openai'").get(orgId) as { id: string }).id;
const storedSecret = () => JSON.parse(loadCredential(new AuthBrokerService(db, vault), orgId, db.prepare("select * from providers where id = ?").get(azure) as unknown as ProviderRow).secret) as Record<string, unknown>;
expect(providers.endpoint(orgId, azure)).toBeNull();
const keep = (deployment: string) => JSON.stringify({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment, secret: "", keepSavedSecret: true });
expect(() => providers.saveCredential({ orgId, providerId: azure, secret: keep("d1"), model: "d1" })).toThrow("There is no saved key to keep; enter the key.");
providers.saveCredential({ orgId, providerId: azure, secret: JSON.stringify({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment: "d1", secret: "az-key" }), model: "d1" });
expect(providers.endpoint(orgId, azure)).toEqual({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment: "d1" });
providers.saveCredential({ orgId, providerId: azure, secret: keep("d2"), model: "d2" });
expect(providers.endpoint(orgId, azure)?.deployment).toBe("d2");
expect(storedSecret()).toEqual({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment: "d2", secret: "az-key" });
});
});