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:
1 parent
5cd413ffcf
commit
a8fbc08df6
13 files changed
+160
-23
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user