Replace one-secret provider dialog with per-auth-type fields

OAuth, account, and cloud providers previously fell into the same
password input as API key providers, which made them impossible to
connect properly. Split the connect dialog by auth type:

- api_key: secret + model (unchanged)
- oauth_pkce / account_subscription: button that opens the provider's
  official sign-in URL in a session-partitioned BrowserWindow, then a
  paste field for the access token returned by the provider
- cloud: multi-field form (base URL, region, project, deployment,
  credential) serialized into the encrypted secret payload

Each catalog entry now carries signInUrl and credentialHelp so the
dialog can render the right instructions without baking copy into the
renderer.

Backed by a new launchProviderSignIn IPC method on ProviderService that
opens a hardened popup (nodeIntegration off, contextIsolation on,
sandbox on, permission denies) and records an audit event.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-21 21:34:18 -04:00
1 parent f2ac1be5de
commit dc791ec984
10 files changed
+283 -54

No files matched your search

@@ -78,6 +78,14 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => {
try {
providers.launchSignInWindow(input.orgId, input.providerId);
return ok(null);
} catch (error) {
return fail("provider_sign_in_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => {
try {
await providers.createResponse(input);
@@ -1,7 +1,9 @@
import type { DatabaseSync } from "node:sqlite";
import { createHash } from "node:crypto";
import { BrowserWindow, session } from "electron";
import type { CreateProviderResponseInput, SaveProviderCredentialInput } from "../../shared/commands";
import { PROVIDER_AUTH_TYPE, PROVIDER_REQUEST_STATE } from "../../shared/domain";
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { SecretVault } from "./secret-vault";
@@ -54,6 +56,48 @@ export class ProviderService {
});
}
launchSignInWindow(orgId: string, providerId: string): void {
const provider = this.db.prepare(`
select id, provider_key, label, auth_type
from providers
where id = ? and org_id = ?
`).get(providerId, orgId) as { id: string; provider_key: string; label: string; auth_type: string } | undefined;
if (!provider) throw new Error("Provider was not found.");
const catalogEntry = PROVIDER_CATALOG.find((entry) => entry.key === provider.provider_key);
if (!catalogEntry) throw new Error(`Provider catalog entry missing for ${provider.provider_key}.`);
if (!catalogEntry.signInUrl) throw new Error(`${provider.label} does not support browser sign-in.`);
const partition = `persist:agentcompany-provider-${provider.id}`;
const providerSession = session.fromPartition(partition);
providerSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
providerSession.setPermissionCheckHandler(() => false);
const win = new BrowserWindow({
width: 1100,
height: 760,
title: provider.label,
webPreferences: {
session: providerSession,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
},
});
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
win.loadURL(catalogEntry.signInUrl);
this.audit.record({
orgId,
entityType: "provider",
entityId: provider.id,
actorType: "employee",
actorId: null,
action: "sign_in_window_opened",
payload: { providerKey: provider.provider_key, authType: provider.auth_type },
});
}
async createResponse(input: CreateProviderResponseInput): Promise<void> {
const prompt = input.prompt.trim();
if (!prompt) throw new Error("Prompt is required.");
+1
View File
@@ -21,6 +21,7 @@ const api: DesktopApi = {
sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input),
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input),
@@ -84,7 +84,15 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
{tab === "members" && <MembersPanel employees={employees} agents={agents} />}
{tab === "billing" && <BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} />}
{tab === "models" && <ModelsPanel providers={providers} agents={agents} />}
{tab === "providers" && <ProvidersPanel orgId={orgId} providers={providers} agents={agents} actions={actions} />}
{tab === "providers" && (
<ProvidersPanel
orgId={orgId}
providers={providers}
catalog={snapshot.providerCatalog}
agents={agents}
actions={actions}
/>
)}
{tab === "skills" && (
<SkillsPanel
orgId={orgId}
@@ -1,32 +1,60 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { AgentRecord, ProviderRecord } from "../../../../shared/domain";
import type {
AgentRecord,
ProviderCatalogEntry,
ProviderRecord,
} from "../../../../shared/domain";
import { ProviderLogo } from "./ProviderLogo";
import type { SnapshotActions } from "../../app/useSnapshot";
interface ProvidersPanelProps {
orgId: string;
providers: ProviderRecord[];
catalog: ProviderCatalogEntry[];
agents: AgentRecord[];
actions: SnapshotActions;
}
export function ProvidersPanel({ orgId, providers, agents, actions }: ProvidersPanelProps) {
interface CloudFields {
baseUrl: string;
region: string;
projectId: string;
deployment: string;
}
const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" };
export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: ProvidersPanelProps) {
const [active, setActive] = useState<ProviderRecord | null>(null);
const [signInLaunched, setSignInLaunched] = useState(false);
const connected = providers.filter((provider) => provider.enabled);
const available = providers.filter((provider) => !provider.enabled);
const activeCatalogEntry = active ? catalog.find((entry) => entry.key === active.providerKey) ?? null : null;
const close = () => {
setActive(null);
setSignInLaunched(false);
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!active) return;
if (!active || !activeCatalogEntry) return;
const form = new FormData(event.currentTarget);
const secret = composeSecret(activeCatalogEntry, form);
void actions.run(() => window.agentCompany.saveProviderCredential({
orgId,
providerId: active.id,
secret: String(form.get("secret") ?? "").trim(),
secret,
model: String(form.get("model") ?? "").trim() || null,
})).then((result) => { if (result) setActive(null); });
})).then((result) => { if (result) close(); });
};
const launchSignIn = () => {
if (!active) return;
setSignInLaunched(true);
void actions.run(() => window.agentCompany.launchProviderSignIn({ orgId, providerId: active.id }));
};
return (
@@ -61,16 +89,126 @@ export function ProvidersPanel({ orgId, providers, agents, actions }: ProvidersP
</>
)}
<Dialog title="Provider credential" open={Boolean(active)} submitLabel="Save" onClose={() => setActive(null)} onSubmit={save}>
<Field label="Provider"><input className="input" value={active?.label ?? ""} readOnly /></Field>
<Field label="Auth type"><input className="input" value={active ? readableAuth(active.authType) : ""} readOnly /></Field>
<Field label="Secret"><input className="input" name="secret" type="password" required autoFocus /></Field>
<Field label="Default model"><input className="input" name="model" defaultValue={active?.model ?? ""} placeholder="Optional" /></Field>
<Dialog
title={active ? `Connect ${active.label}` : "Connect provider"}
open={Boolean(active)}
submitLabel="Save"
onClose={close}
onSubmit={save}
>
{active && activeCatalogEntry && (
<ProviderConnectFields
provider={active}
entry={activeCatalogEntry}
signInLaunched={signInLaunched}
onLaunchSignIn={launchSignIn}
/>
)}
</Dialog>
</div>
);
}
function ProviderConnectFields({
provider,
entry,
signInLaunched,
onLaunchSignIn,
}: {
provider: ProviderRecord;
entry: ProviderCatalogEntry;
signInLaunched: boolean;
onLaunchSignIn: () => void;
}) {
if (entry.authType === "api_key") {
return (
<>
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
<Field label="API key"><input className="input" name="secret" type="password" required autoFocus /></Field>
<Field label="Default model"><input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} /></Field>
</>
);
}
if (entry.authType === "oauth_pkce" || entry.authType === "account_subscription") {
return (
<>
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
<div className="row" style={{ gap: 8 }}>
<Button tone={signInLaunched ? "ghost" : "primary"} onClick={onLaunchSignIn}>
{signInLaunched ? "Re-open sign-in window" : `Open ${entry.label} sign-in`}
</Button>
{entry.signInUrl && <span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{entry.signInUrl}</span>}
</div>
<Field label="Access token"><input className="input" name="secret" type="password" required /></Field>
<Field label="Default model"><input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} /></Field>
</>
);
}
return <CloudFieldsForm entry={entry} provider={provider} />;
}
function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
const [state, setState] = useState<CloudFields>(CLOUD_DEFAULTS);
const update = (patch: Partial<CloudFields>) => setState((current) => ({ ...current, ...patch }));
return (
<>
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
<Field label="Base URL">
<input
className="input"
name="cloudBaseUrl"
value={state.baseUrl}
onChange={(event) => update({ baseUrl: event.target.value })}
placeholder="https://"
/>
</Field>
<Field label="Region">
<input
className="input"
name="cloudRegion"
value={state.region}
onChange={(event) => update({ region: event.target.value })}
/>
</Field>
<Field label="Project / resource">
<input
className="input"
name="cloudProject"
value={state.projectId}
onChange={(event) => update({ projectId: event.target.value })}
/>
</Field>
<Field label="Deployment / model id">
<input
className="input"
name="cloudDeployment"
value={state.deployment}
onChange={(event) => update({ deployment: event.target.value })}
/>
</Field>
<Field label="Credential">
<input className="input" name="secret" type="password" required autoFocus />
</Field>
<Field label="Default model">
<input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} />
</Field>
</>
);
}
function composeSecret(entry: ProviderCatalogEntry, form: FormData): string {
const raw = String(form.get("secret") ?? "").trim();
if (entry.authType !== "cloud") return raw;
const payload = {
baseUrl: String(form.get("cloudBaseUrl") ?? "").trim(),
region: String(form.get("cloudRegion") ?? "").trim(),
projectId: String(form.get("cloudProject") ?? "").trim(),
deployment: String(form.get("cloudDeployment") ?? "").trim(),
secret: raw,
};
return JSON.stringify(payload);
}
function ProviderRow({
provider,
agents,
+5
View File
@@ -110,6 +110,11 @@ export interface SaveProviderCredentialInput {
model: string | null;
}
export interface LaunchProviderSignInInput {
orgId: string;
providerId: string;
}
export interface CreateProviderResponseInput {
orgId: string;
providerId: string;
@@ -18,6 +18,7 @@ import type {
DeleteOrganizationInput,
ImportFileInput,
ImportSkillDirectoryInput,
LaunchProviderSignInInput,
LaunchSocialLoginInput,
ResolveAgentSkillsInput,
SaveProviderCredentialInput,
@@ -47,6 +48,7 @@ export interface DesktopApi {
sendMessage(input: SendMessageInput): Promise<DesktopResult<AppSnapshot>>;
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
bindSkill(input: BindSkillInput): Promise<DesktopResult<AppSnapshot>>;
+2
View File
@@ -279,6 +279,8 @@ export interface ProviderCatalogEntry {
authType: ProviderAuthType;
defaultModel: string | null;
capabilityTags: string[];
signInUrl: string | null;
credentialHelp: string;
}
export interface ProviderRequestRecord {
+1
View File
@@ -13,6 +13,7 @@ export const IPC_CHANNELS = {
sendMessage: "agentcompany:send-message",
markChatSpaceRead: "agentcompany:mark-chat-space-read",
saveProviderCredential: "agentcompany:save-provider-credential",
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
createProviderResponse: "agentcompany:create-provider-response",
importSkillDirectory: "agentcompany:import-skill-directory",
bindSkill: "agentcompany:bind-skill",
+63 -43
View File
@@ -7,6 +7,8 @@ export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: "gpt-5.1",
capabilityTags: ["responses", "tools", "background"],
signInUrl: null,
credentialHelp: "Generate an API key at platform.openai.com → API keys.",
},
{
key: "anthropic",
@@ -14,6 +16,8 @@ export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: "claude-sonnet-4-5",
capabilityTags: ["messages", "tool-use", "token-bucket"],
signInUrl: null,
credentialHelp: "Generate an API key at console.anthropic.com → API keys.",
},
{
key: "xai",
@@ -21,54 +25,70 @@ export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: "grok-4",
capabilityTags: ["chat", "reasoning"],
},
{
key: "github-copilot",
label: "GitHub Copilot",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: null,
capabilityTags: ["account", "coding"],
},
{
key: "google-antigravity",
label: "Google Antigravity",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: null,
capabilityTags: ["account", "coding"],
},
{
key: "gpt-account",
label: "GPT account",
authType: PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION,
defaultModel: null,
capabilityTags: ["codex", "subscription"],
},
{
key: "azure-openai",
label: "Azure OpenAI",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: null,
capabilityTags: ["enterprise", "regional"],
},
{
key: "aws-bedrock",
label: "AWS Bedrock",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: null,
capabilityTags: ["enterprise", "multi-model"],
},
{
key: "vertex-ai",
label: "Vertex AI",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: null,
capabilityTags: ["enterprise", "regional"],
signInUrl: null,
credentialHelp: "Generate an API key at console.x.ai → API keys.",
},
{
key: "groq",
label: "Groq",
authType: PROVIDER_AUTH_TYPE.API_KEY,
defaultModel: null,
defaultModel: "groq/compound",
capabilityTags: ["inference", "low-latency"],
signInUrl: null,
credentialHelp: "Generate an API key at console.groq.com → API keys.",
},
{
key: "github-copilot",
label: "GitHub Copilot",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: "claude-opus-4-5",
capabilityTags: ["account", "coding"],
signInUrl: "https://github.com/login",
credentialHelp: "Sign in, then copy the personal access token (scope copilot) from github.com → Settings → Developer settings.",
},
{
key: "google-antigravity",
label: "Google Antigravity",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: "claude-sonnet-4-5",
capabilityTags: ["account", "coding"],
signInUrl: "https://antigravity.google/",
credentialHelp: "Sign in to Antigravity, then paste the session token shown in the dashboard.",
},
{
key: "gpt-account",
label: "GPT account",
authType: PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION,
defaultModel: "gpt-5.3-codex",
capabilityTags: ["codex", "subscription"],
signInUrl: "https://chatgpt.com/codex",
credentialHelp: "Sign in to ChatGPT Plus/Pro, then paste the session access token from chatgpt.com/codex/settings.",
},
{
key: "azure-openai",
label: "Azure OpenAI",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: "gpt-5.4",
capabilityTags: ["enterprise", "regional"],
signInUrl: null,
credentialHelp: "Set base URL (e.g. https://<resource>.openai.azure.com), deployment name, and the api-key from Azure portal.",
},
{
key: "aws-bedrock",
label: "AWS Bedrock",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: "anthropic.claude-sonnet-4-5",
capabilityTags: ["enterprise", "multi-model"],
signInUrl: null,
credentialHelp: "Provide AWS region plus an access key + secret access key with bedrock:InvokeModel permission.",
},
{
key: "vertex-ai",
label: "Vertex AI",
authType: PROVIDER_AUTH_TYPE.CLOUD,
defaultModel: "gemini-2.5-pro",
capabilityTags: ["enterprise", "regional"],
signInUrl: null,
credentialHelp: "Provide GCP project id, region, and a service-account JSON key with aiplatform.user role.",
},
];