From 0924ba86dd4648b025144bb6bc510381252fe2a9 Mon Sep 17 00:00:00 2001 From: Harshith Pasupuleti Date: Sat, 26 Sep 2026 11:56:07 +0530 Subject: [PATCH 1/8] Add multiple account support per provider Introduce provider instances so a user can connect more than one account for the same agent (e.g. personal + work Codex), each running in its own isolated process and credential root. - New AcpAgentInstance contract and agent_instances SQLite table with CRUD, default seeding, and instance-to-descriptor resolution - Route spawns and thread/session state by instance id; keep the driver id for display, install detection, and analytics - Isolate credentials via per-instance env (CODEX_HOME, CLAUDE_CONFIG_DIR, etc.) without copying files; encrypt sensitive values at rest with safeStorage - Launch the CLI's own interactive login per account; persist the preferred account across restarts - Settings Accounts UI to add/remove/sign in accounts; onboarding stays provider-level and the composer surfaces accounts of selected providers - Remote models endpoint exposes accounts grouped by provider --- contracts/acp.ts | 68 ++++ contracts/remote.ts | 2 + contracts/threads.ts | 6 +- electron/agent-connection-manager.ts | 49 ++- electron/agent-instances.test.ts | 177 ++++++++++ electron/agent-instances.ts | 374 +++++++++++++++++++++ electron/agents/registry.ts | 31 +- electron/connection-lifecycle.ts | 4 + electron/db.ts | 44 +++ electron/main.ts | 63 ++++ electron/preload.ts | 15 + electron/remote-server.ts | 20 +- src/components/agent-accounts-settings.tsx | 249 ++++++++++++++ src/components/agent-panel.tsx | 7 +- src/components/agent-selector.tsx | 11 +- src/electron.d.ts | 12 + src/lib/agent-selection.ts | 24 ++ src/remote/App.tsx | 23 +- src/remote/model-groups.test.ts | 30 ++ src/remote/model-groups.ts | 22 ++ src/settings/app.tsx | 17 + src/store/agent-instances-store.ts | 65 ++++ 22 files changed, 1288 insertions(+), 25 deletions(-) create mode 100644 electron/agent-instances.test.ts create mode 100644 electron/agent-instances.ts create mode 100644 src/components/agent-accounts-settings.tsx create mode 100644 src/lib/agent-selection.ts create mode 100644 src/remote/model-groups.test.ts create mode 100644 src/remote/model-groups.ts create mode 100644 src/store/agent-instances-store.ts diff --git a/contracts/acp.ts b/contracts/acp.ts index 7c55f3a..d76689b 100644 --- a/contracts/acp.ts +++ b/contracts/acp.ts @@ -40,6 +40,13 @@ export type AcpAgentInstallKind = "binary" | "npx" | "mock"; /** Registry entry describing an ACP agent Pipper can spawn. */ export interface AcpAgentDescriptor { id: string; + /** + * The underlying provider/driver this descriptor belongs to. For a plain + * driver descriptor this is omitted and `id` is the driver id; for a + * materialized account instance (`AcpAgentInstance`) `id` is the instance id + * and this points back at the driver for display/analytics/install metadata. + */ + driverId?: string; name: string; /** Display name shown in the UI. */ displayName: string; @@ -87,6 +94,67 @@ export interface AgentProbeResult { authMethods?: AuthMethod[]; } +/** + * A single environment variable supplied to a provider instance's child + * process. Values never leave the main process in cleartext: a `sensitive` + * value is redacted before an instance is sent to the renderer. + */ +export interface AcpAgentInstanceEnvVar { + name: string; + value: string; + /** When true, the value is redacted in renderer-facing copies. */ + sensitive?: boolean; +} + +/** + * One named account/configuration of a provider driver. Pipper keeps a + * separate child process, environment, and credential root per instance so + * two accounts of the same driver (e.g. personal + work Codex) can coexist and + * run side by side. An instance with `id === driverId` is the legacy/default + * configuration that uses the ambient CLI login. + */ +export interface AcpAgentInstance { + /** Routing key passed to {@link AcpAgentDescriptor.id} when spawning. */ + id: string; + /** The provider driver this instance is a configuration of. */ + driverId: string; + /** User-facing label, e.g. "Codex — Work". */ + displayName: string; + enabled: boolean; + /** Per-instance environment overrides, merged over the driver defaults. */ + env?: AcpAgentInstanceEnvVar[]; + /** Driver-specific, opaque configuration (e.g. a profile directory). */ + config?: Record; + createdAt?: number; + updatedAt?: number; +} + +/** Create/update payload for a provider instance. */ +export interface AcpAgentInstanceInput { + driverId: string; + displayName: string; + /** Optional explicit id; generated from driver + label when omitted. */ + id?: string; + enabled?: boolean; + env?: AcpAgentInstanceEnvVar[]; + config?: Record; +} + +/** Per-driver account-creation capabilities, surfaced to the settings UI. */ +export interface AgentAccountSchema { + driverId: string; + displayName: string; + icon?: string; + /** Env var that points the CLI at an isolated credential root, if any. */ + profileEnvVar: string | null; + /** Env var that carries an explicit credential value, if any. */ + authEnvVar: string | null; + /** True when a driver beyond the ambient default can be configured. */ + supportsMultipleAccounts: boolean; + /** True when the driver has an interactive sign-in command to launch. */ + supportsLogin: boolean; +} + /** * Account-level subscription rate limit for a session, when the agent reports * one. Distinct from `used`/`size` (per-turn context window): this describes the diff --git a/contracts/remote.ts b/contracts/remote.ts index f535efb..c743ff0 100644 --- a/contracts/remote.ts +++ b/contracts/remote.ts @@ -11,6 +11,8 @@ export interface RemoteProject { export interface RemoteModel { id: string; name: string; + /** Provider/driver display name; used to group accounts on the phone. */ + provider?: string; } export interface RemoteThreadSummary { diff --git a/contracts/threads.ts b/contracts/threads.ts index b093b5a..5fef4f6 100644 --- a/contracts/threads.ts +++ b/contracts/threads.ts @@ -1,7 +1,11 @@ export interface Thread { id: string; project_id: string; - /** Which ACP agent owns this thread (e.g. "cursor-acp@1.0"). */ + /** + * Which ACP provider instance owns this thread — the spawn routing key. + * Equals the driver id for a driver's default account, or an instance id + * (e.g. "codex-acp:work") for an additional account. + */ agent_id: string; /** ACP session.id from session/new (or session/resume). */ agent_session_id: string; diff --git a/electron/agent-connection-manager.ts b/electron/agent-connection-manager.ts index 4a27af9..aa933fb 100644 --- a/electron/agent-connection-manager.ts +++ b/electron/agent-connection-manager.ts @@ -19,7 +19,7 @@ import type { import type { OpenTabsState, Thread } from "../contracts/threads.ts"; import { readOpenTabsState, recordThreadSwitch } from "./open-tabs.ts"; import { getProject } from "./projects.ts"; -import { getSelectedAgentIds } from "./db.ts"; +import { getAppSetting, setAppSetting, getSelectedAgentIds } from "./db.ts"; import { setActiveProjectId } from "./session.ts"; import { getThread, @@ -39,6 +39,7 @@ import { import { normalizeWorkspacePath, pickWorkspaceThread } from "../contracts/workspace-scope.ts"; import { isLiveWorktree } from "./worktree-manager.ts"; import { getAgentDescriptor, getDefaultAgentId, listRegisteredAgents } from "./agents/registry.ts"; +import { listAgentInstanceDescriptors } from "./agent-instances.ts"; import { ACP_SWITCH_PHASE_TIMEOUT_MS, ConnectionLifecycle, @@ -92,6 +93,27 @@ import type { MonitorSwitchRecord, } from "../contracts/monitor.ts"; +/** Persisted pointer to the last-used provider instance. */ +const PREFERRED_INSTANCE_KEY = "preferred_agent_instance"; + +function loadPreferredAgentId(): string { + try { + const stored = getAppSetting(PREFERRED_INSTANCE_KEY); + if (stored && getAgentDescriptor(stored)) return stored; + } catch { + // Database may not be ready in some embedding contexts; fall back. + } + return getDefaultAgentId(); +} + +function persistPreferredAgentId(agentId: string): void { + try { + setAppSetting(PREFERRED_INSTANCE_KEY, agentId); + } catch { + // Non-fatal: preference simply won't survive a restart. + } +} + function modelOptionsFromConfig( options: SessionConfigOption[] | undefined, ): Array<{ modelId: string; name: string; provider?: string }> { @@ -219,7 +241,7 @@ export class AgentConnectionManager { private connecting: Promise | null = null; private activeProjectId: string | null = null; private activeThreadId: string | null = null; - private preferredAgentId: string = getDefaultAgentId(); + private preferredAgentId: string = loadPreferredAgentId(); private readonly sessions = new ThreadSessionRegistry(); /** * Session replay is delivered as session/update notifications while @@ -371,8 +393,11 @@ export class AgentConnectionManager { } listAgents(): AcpAgentDescriptor[] { - // Always re-probe PATH so onboarding reflects install state. - return listRegisteredAgents(); + // Always re-probe PATH so onboarding reflects install state. When accounts + // are configured, surface each instance (default instances reuse the driver + // id, so this is a no-op for single-account users). + const instances = listAgentInstanceDescriptors(); + return instances.length ? instances : listRegisteredAgents(); } async getModelCatalogs(): Promise< @@ -384,8 +409,18 @@ export class AgentConnectionManager { // needs authentication is skipped; its catalog can still be populated by // a later successful session. const selectedAgentIds = getSelectedAgentIds(); + // Selections are provider-level (a driver id or a default instance id), but + // catalogs are keyed per instance. Warm every enabled instance whose driver + // is selected so accounts added later in Settings have catalogs too. + const selectedSet = new Set(selectedAgentIds); + const warmIds = new Set(selectedAgentIds); + for (const instance of listAgentInstanceDescriptors()) { + const driver = instance.driverId ?? instance.id; + if (selectedSet.has(instance.id) || selectedSet.has(driver)) warmIds.add(instance.id); + } + const agentsToWarm = [...warmIds]; await Promise.all( - selectedAgentIds.map(async (agentId) => { + agentsToWarm.map(async (agentId) => { try { await this.acquireConnection(agentId); } catch { @@ -434,7 +469,7 @@ export class AgentConnectionManager { ? (getProject(this.activeProjectId)?.path ?? process.cwd()) : process.cwd()); await Promise.all( - selectedAgentIds.map(async (agentId) => { + agentsToWarm.map(async (agentId) => { if (result[agentId]?.length) return; const live = this.lifecycle.getCached(agentId); if (!live) return; @@ -794,6 +829,7 @@ export class AgentConnectionManager { ); } this.preferredAgentId = agentId; + persistPreferredAgentId(agentId); } /** Bridge-event output goes through RendererBroadcaster (see that module). */ @@ -1112,6 +1148,7 @@ export class AgentConnectionManager { const live = await this.acquireConnection(agentId); this.lifecycle.setActive(live); this.preferredAgentId = agentId; + persistPreferredAgentId(agentId); if (previousAgentId && previousAgentId !== live.agentId) { this.captureAnalytics?.("agent_switched", { from_agent_id: previousAgentId, diff --git a/electron/agent-instances.test.ts b/electron/agent-instances.test.ts new file mode 100644 index 0000000..137d7bd --- /dev/null +++ b/electron/agent-instances.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AcpAgentDescriptor } from "../contracts/acp.ts"; + +vi.mock("electron", () => ({ + app: { getPath: () => process.env.PIPPER_LIBRARY_PATH ?? process.env.TMPDIR ?? "/tmp" }, + // Reversible stand-in so the encryption round-trip is observable without a + // real OS keychain. + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`sealed:${value}`, "utf8"), + decryptString: (buffer: Buffer) => buffer.toString("utf8").replace(/^sealed:/, ""), + }, +})); + +// Deterministic driver catalog; the real registry probes PATH, which we don't +// want to depend on here. Mirrors the shape agent-instances consumes. +vi.mock("./agents/registry.ts", () => { + const drivers: AcpAgentDescriptor[] = [ + { + id: "codex-acp", + name: "codex-cli", + displayName: "Codex", + command: "codex-acp", + args: [], + env: { SHARED: "driver" }, + }, + { + id: "claude-agent-acp", + name: "claude-code", + displayName: "Claude", + command: "npx", + args: [], + }, + ]; + return { + listRegisteredAgents: () => drivers.map((driver) => ({ ...driver })), + setInstanceDescriptorProvider: () => {}, + descriptorDriverId: (descriptor: AcpAgentDescriptor) => descriptor.driverId ?? descriptor.id, + }; +}); + +let root: string | null = null; + +beforeEach(() => { + vi.resetModules(); + root = mkdtempSync(join(tmpdir(), "pipper-agent-instances-")); + process.env.PIPPER_LIBRARY_PATH = root; +}); + +afterEach(async () => { + const { closeDb } = await import("./db.ts"); + closeDb(); + delete process.env.PIPPER_LIBRARY_PATH; + if (root) rmSync(root, { recursive: true, force: true }); + root = null; +}); + +async function load() { + const db = await import("./db.ts"); + db.getDb(); + const mod = await import("./agent-instances.ts"); + return mod; +} + +describe("agent instances", () => { + test("seeds one default instance per driver", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const ids = mod.listAgentInstances().map((instance) => instance.id); + expect(ids).toContain("codex-acp"); + expect(ids).toContain("claude-agent-acp"); + // Defaults reuse the driver id so legacy threads/selections resolve. + const codex = mod.getAgentInstance("codex-acp"); + expect(codex?.driverId).toBe("codex-acp"); + }); + + test("creates a named account with a distinct id", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "codex-acp", + displayName: "Work", + env: [{ name: "CODEX_HOME", value: "/tmp/work" }], + }); + expect(created.id).toBe("codex-acp:work"); + expect(created.driverId).toBe("codex-acp"); + }); + + test("auto-isolates a new account's credential root when env is omitted", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const profileVar = (created.env ?? []).find((entry) => entry.name === "CODEX_HOME"); + expect(profileVar?.value).toContain("accounts"); + // The default instance keeps the ambient login (no forced profile dir). + const defaultInstance = mod.getAgentInstance("codex-acp"); + expect(defaultInstance?.env).toBeUndefined(); + }); + + test("resolves an instance descriptor with merged env", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + mod.createAgentInstance({ + driverId: "codex-acp", + displayName: "Work", + env: [{ name: "CODEX_HOME", value: "/tmp/work" }], + }); + const descriptor = mod.resolveAgentInstanceDescriptor("codex-acp:work"); + expect(descriptor?.id).toBe("codex-acp:work"); + expect(descriptor?.driverId).toBe("codex-acp"); + // Driver env survives; instance env overlays it. + expect(descriptor?.env).toEqual({ SHARED: "driver", CODEX_HOME: "/tmp/work" }); + }); + + test("returns null for an unknown instance", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + expect(mod.resolveAgentInstanceDescriptor("does-not-exist")).toBeNull(); + }); + + test("redacts sensitive env values for the renderer", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "codex-acp", + displayName: "Work", + env: [ + { name: "CODEX_HOME", value: "/tmp/work" }, + { name: "CODEX_API_KEY", value: "secret", sensitive: true }, + ], + }); + const redacted = mod.redactInstance(created); + const byName = Object.fromEntries((redacted.env ?? []).map((e) => [e.name, e.value])); + expect(byName.CODEX_HOME).toBe("/tmp/work"); + expect(byName.CODEX_API_KEY).toBe(""); + }); + + test("refuses to delete a driver's default instance", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + expect(() => mod.deleteAgentInstance("codex-acp")).toThrow(/default instance/); + }); + + test("encrypts sensitive env at rest and decrypts on read", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "cursor-acp", + displayName: "Work", + env: [{ name: "CURSOR_API_KEY", value: "super-secret", sensitive: true }], + }); + // Raw row holds ciphertext (safeStorage marker), never the cleartext. + const db = (await import("./db.ts")).getDb(); + const row = db.prepare("SELECT env_json FROM agent_instances WHERE id = ?").get(created.id) as { + env_json: string; + }; + expect(row.env_json).toContain("enc:"); + expect(row.env_json).not.toContain("super-secret"); + // Round-trips back to the original value for spawn env. + const read = mod.getAgentInstance(created.id); + expect((read?.env ?? []).find((e) => e.name === "CURSOR_API_KEY")?.value).toBe("super-secret"); + }); + + test("builds a login command that exports the account's credential root", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const command = mod.buildInstanceLoginCommand(created); + expect(command).toContain("CODEX_HOME="); + expect(command).toContain("codex login"); + // API-key providers have no interactive login. + expect(mod.buildInstanceLoginCommand({ ...created, driverId: "cursor-acp" })).toBeNull(); + }); +}); diff --git a/electron/agent-instances.ts b/electron/agent-instances.ts new file mode 100644 index 0000000..5a4b878 --- /dev/null +++ b/electron/agent-instances.ts @@ -0,0 +1,374 @@ +import { join } from "node:path"; +import { app, safeStorage } from "electron"; +import type { + AcpAgentDescriptor, + AcpAgentInstance, + AcpAgentInstanceEnvVar, + AcpAgentInstanceInput, + AgentAccountSchema, +} from "../contracts/acp.ts"; +import { getDb } from "./db.ts"; +import { listRegisteredAgents, setInstanceDescriptorProvider } from "./agents/registry.ts"; + +interface AgentInstanceRow { + id: string; + driver_id: string; + display_name: string; + enabled: number; + env_json: string | null; + config_json: string | null; + created_at: number; + updated_at: number; +} + +/** + * The environment variable that points a given CLI at an isolated credential + * root. Providers absent from this map are isolated with explicit keys/tokens + * instead of a directory (e.g. Cursor's CURSOR_API_KEY, Gemini's API key). + */ +export const PROFILE_ENV_BY_DRIVER: Record = { + "codex-acp": "CODEX_HOME", + "claude-agent-acp": "CLAUDE_CONFIG_DIR", + "grok-acp": "GROK_HOME", + "copilot-acp": "COPILOT_HOME", + "opencode-acp": "XDG_DATA_HOME", + "antigravity-acp": "AGY_HOME", +}; + +/** + * Drivers that are isolated with an explicit key/token rather than a profile + * directory. Used to prompt for a credential when adding an account. + */ +export const AUTH_ENV_BY_DRIVER: Record = { + "cursor-acp": "CURSOR_API_KEY", + "gemini-acp": "GEMINI_API_KEY", +}; + +/** + * Interactive sign-in command per driver, run once per account inside the + * user's terminal (never in-process) so the CLI performs its own OAuth flow + * and owns its credentials. Drivers that authenticate purely via an API key + * are absent. Best-effort: verify against each CLI's current docs. + */ +export const LOGIN_COMMAND_BY_DRIVER: Record = { + "codex-acp": "codex login", + "claude-agent-acp": "claude auth login", + "grok-acp": "grok login", + "copilot-acp": "copilot", + "opencode-acp": "opencode auth login", + "antigravity-acp": "agy", +}; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * The shell command a user runs to sign an account in, with its credential + * root exported inline. Returns null when the driver has no interactive login + * (API-key providers) so the UI can prompt for a key instead. + */ +export function buildInstanceLoginCommand(instance: AcpAgentInstance): string | null { + const login = LOGIN_COMMAND_BY_DRIVER[instance.driverId]; + if (!login) return null; + const profileVar = PROFILE_ENV_BY_DRIVER[instance.driverId]; + if (!profileVar) return login; + const entry = (instance.env ?? []).find((item) => item.name === profileVar); + if (!entry?.value) return login; + return `${profileVar}=${shellQuote(entry.value)} ${login}`; +} + +function parseEnv(raw: string | null): AcpAgentInstanceEnvVar[] | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as AcpAgentInstanceEnvVar[]; + if (!Array.isArray(parsed)) return undefined; + return parsed.map((entry) => + entry.sensitive ? { ...entry, value: decryptSecret(entry.value) } : entry, + ); + } catch { + return undefined; + } +} + +/** Marker for values protected with the OS keychain via `safeStorage`. */ +const ENC_PREFIX = "enc:"; + +function encryptSecret(value: string): string { + if (!value) return value; + try { + if (safeStorage?.isEncryptionAvailable()) { + return ENC_PREFIX + safeStorage.encryptString(value).toString("base64"); + } + } catch { + // Fall through to plaintext (e.g. unsupported platform). + } + return value; +} + +function decryptSecret(value: string): string { + if (!value.startsWith(ENC_PREFIX)) return value; + try { + return safeStorage.decryptString(Buffer.from(value.slice(ENC_PREFIX.length), "base64")); + } catch { + return ""; + } +} + +/** Encrypt sensitive values before they touch disk. */ +function serializeEnv(env: AcpAgentInstanceEnvVar[] | undefined): string | null { + if (!env?.length) return null; + return JSON.stringify( + env.map((entry) => (entry.sensitive ? { ...entry, value: encryptSecret(entry.value) } : entry)), + ); +} + +function parseConfig(raw: string | null): Record | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === "object" ? parsed : undefined; + } catch { + return undefined; + } +} + +function rowToInstance(row: AgentInstanceRow): AcpAgentInstance { + return { + id: row.id, + driverId: row.driver_id, + displayName: row.display_name, + enabled: row.enabled !== 0, + env: parseEnv(row.env_json), + config: parseConfig(row.config_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function slug(input: string): string { + const value = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return value || "account"; +} + +/** Redact sensitive env values before sending an instance to the renderer. */ +export function redactInstance(instance: AcpAgentInstance): AcpAgentInstance { + if (!instance.env?.some((entry) => entry.sensitive)) return instance; + return { + ...instance, + env: instance.env.map((entry) => (entry.sensitive ? { ...entry, value: "" } : entry)), + }; +} + +export function listAgentInstances(): AcpAgentInstance[] { + const rows = getDb() + .prepare("SELECT * FROM agent_instances ORDER BY driver_id ASC, created_at ASC, rowid ASC") + .all() as unknown as AgentInstanceRow[]; + return rows.map(rowToInstance); +} + +/** Renderer-facing copy with sensitive env values redacted. */ +export function listAgentInstancesForRenderer(): AcpAgentInstance[] { + return listAgentInstances().map(redactInstance); +} + +export function getAgentInstance(id: string): AcpAgentInstance | null { + const row = getDb().prepare("SELECT * FROM agent_instances WHERE id = ?").get(id) as + | AgentInstanceRow + | undefined; + return row ? rowToInstance(row) : null; +} + +function instanceIdFor(driverId: string, label: string): string { + const base = `${driverId}:${slug(label)}`; + const db = getDb(); + let candidate = base; + let suffix = 2; + // Ids are the spawn routing key; collisions would alias two accounts. + while (db.prepare("SELECT 1 FROM agent_instances WHERE id = ?").get(candidate)) { + candidate = `${base}-${suffix++}`; + } + return candidate; +} + +/** Directory an account's CLI should treat as its isolated credential root. */ +export function profileDirForInstance(driverId: string, instanceId: string): string { + return join(app.getPath("userData"), "accounts", driverId, slug(instanceId)); +} + +/** + * Default env for a fresh non-default account: point the driver's credential + * root at a Pipper-owned directory. Returns `[]` for key/token-based providers. + */ +export function suggestProfileEnv(driverId: string, instanceId: string): AcpAgentInstanceEnvVar[] { + const envVar = PROFILE_ENV_BY_DRIVER[driverId]; + if (!envVar) return []; + return [{ name: envVar, value: profileDirForInstance(driverId, instanceId), sensitive: false }]; +} + +export function createAgentInstance(input: AcpAgentInstanceInput): AcpAgentInstance { + const driverId = input.driverId.trim(); + if (!driverId) throw new Error("driverId is required"); + const displayName = input.displayName.trim() || driverId; + const id = (input.id?.trim() || instanceIdFor(driverId, displayName)).trim(); + // Additional accounts default to an isolated credential root so two logins + // of the same driver cannot share the ambient config. The default instance + // (id === driverId) intentionally keeps the ambient login. + const env = + input.env && input.env.length + ? input.env + : id === driverId + ? undefined + : suggestProfileEnv(driverId, id); + const now = Date.now(); + const row: AgentInstanceRow = { + id, + driver_id: driverId, + display_name: displayName, + enabled: input.enabled === false ? 0 : 1, + env_json: serializeEnv(env), + config_json: input.config ? JSON.stringify(input.config) : null, + created_at: now, + updated_at: now, + }; + getDb() + .prepare( + `INSERT INTO agent_instances (id, driver_id, display_name, enabled, env_json, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + row.id, + row.driver_id, + row.display_name, + row.enabled, + row.env_json, + row.config_json, + row.created_at, + row.updated_at, + ); + return rowToInstance(row); +} + +export function updateAgentInstance( + id: string, + input: Partial, +): AcpAgentInstance | null { + const existing = getAgentInstance(id); + if (!existing) return null; + const updated: AcpAgentInstance = { + ...existing, + displayName: input.displayName?.trim() || existing.displayName, + enabled: input.enabled ?? existing.enabled, + env: input.env ?? existing.env, + config: input.config ?? existing.config, + updatedAt: Date.now(), + }; + getDb() + .prepare( + `UPDATE agent_instances SET display_name = ?, enabled = ?, env_json = ?, config_json = ?, updated_at = ? + WHERE id = ?`, + ) + .run( + updated.displayName, + updated.enabled ? 1 : 0, + serializeEnv(updated.env), + updated.config ? JSON.stringify(updated.config) : null, + updated.updatedAt ?? Date.now(), + id, + ); + return updated; +} + +export function deleteAgentInstance(id: string): void { + // The default instance (id === driver id) is structurally required: it backs + // the driver's ambient login and legacy thread rows. + const existing = getAgentInstance(id); + if (existing && existing.id === existing.driverId) { + throw new Error("Cannot delete a driver's default instance"); + } + getDb().prepare("DELETE FROM agent_instances WHERE id = ?").run(id); +} + +function driverDescriptorById(): Map { + return new Map(listRegisteredAgents().map((descriptor) => [descriptor.id, descriptor])); +} + +function materialize(instance: AcpAgentInstance, driver: AcpAgentDescriptor): AcpAgentDescriptor { + const env: Record = { ...driver.env }; + for (const entry of instance.env ?? []) { + if (entry.name) env[entry.name] = entry.value; + } + return { + ...driver, + id: instance.id, + driverId: instance.driverId, + displayName: instance.displayName, + env, + }; +} + +/** Resolve an instance id to a spawnable descriptor with merged env. */ +export function resolveAgentInstanceDescriptor(instanceId: string): AcpAgentDescriptor | null { + const instance = getAgentInstance(instanceId); + if (!instance || !instance.enabled) return null; + const driver = driverDescriptorById().get(instance.driverId); + if (!driver) return null; + return materialize(instance, driver); +} + +/** Every enabled instance as a descriptor, for agent/account pickers. */ +export function listAgentInstanceDescriptors(): AcpAgentDescriptor[] { + const drivers = driverDescriptorById(); + const out: AcpAgentDescriptor[] = []; + for (const instance of listAgentInstances()) { + if (!instance.enabled) continue; + const driver = drivers.get(instance.driverId); + if (!driver) continue; + out.push(materialize(instance, driver)); + } + return out; +} + +/** Per-driver account-creation capabilities, for the settings UI. */ +export function listAgentAccountSchemas(): AgentAccountSchema[] { + return listRegisteredAgents().map((driver) => ({ + driverId: driver.id, + displayName: driver.displayName, + icon: driver.icon, + profileEnvVar: PROFILE_ENV_BY_DRIVER[driver.id] ?? null, + authEnvVar: AUTH_ENV_BY_DRIVER[driver.id] ?? null, + supportsMultipleAccounts: Boolean( + PROFILE_ENV_BY_DRIVER[driver.id] ?? AUTH_ENV_BY_DRIVER[driver.id], + ), + supportsLogin: Boolean(LOGIN_COMMAND_BY_DRIVER[driver.id]), + })); +} + +/** + * Backfill the required default instance for every known driver. The default + * instance reuses the driver id so existing threads, selections, and ambient + * CLI logins keep working with no migration. + */ +export function ensureDefaultAgentInstances(): void { + const db = getDb(); + const insert = db.prepare( + `INSERT OR IGNORE INTO agent_instances (id, driver_id, display_name, enabled, env_json, config_json, created_at, updated_at) + VALUES (?, ?, ?, 1, NULL, NULL, ?, ?)`, + ); + const now = Date.now(); + for (const driver of listRegisteredAgents()) { + insert.run(driver.id, driver.id, driver.displayName, now, now); + } +} + +/** + * Seed defaults and register the instance resolver with the registry. Call + * once after the database is ready (agent-instances.ts owns the only writer). + */ +export function installAgentInstanceProvider(): void { + ensureDefaultAgentInstances(); + setInstanceDescriptorProvider((instanceId) => resolveAgentInstanceDescriptor(instanceId)); +} diff --git a/electron/agents/registry.ts b/electron/agents/registry.ts index 443b13b..b8020c2 100644 --- a/electron/agents/registry.ts +++ b/electron/agents/registry.ts @@ -13,6 +13,27 @@ interface RegistryFile { /** Directory of this module (avoid naming `__dirname` — electron-vite injects that). */ const registryDir = dirname(fileURLToPath(import.meta.url)); +/** + * Resolves a *provider instance* id (e.g. `codex-acp:work`) to a materialized + * descriptor whose `id` is the instance id and whose `env` carries the + * instance's isolated credential root. Installed by `agent-instances.ts` at + * startup so this module stays free of a database dependency (and stays + * unit-testable). + */ +export type AgentInstanceDescriptorProvider = (instanceId: string) => AcpAgentDescriptor | null; +let instanceDescriptorProvider: AgentInstanceDescriptorProvider | null = null; + +export function setInstanceDescriptorProvider( + provider: AgentInstanceDescriptorProvider | null, +): void { + instanceDescriptorProvider = provider; +} + +/** The driver id behind a descriptor (instance descriptors carry `driverId`). */ +export function descriptorDriverId(descriptor: AcpAgentDescriptor): string { + return descriptor.driverId ?? descriptor.id; +} + /** Built-in catalog — used when config.json is missing or incomplete. */ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ { @@ -420,6 +441,8 @@ export function listRegisteredAgents(): AcpAgentDescriptor[] { } export function getAgentDescriptor(agentId: string): AcpAgentDescriptor | null { + const fromInstance = instanceDescriptorProvider?.(agentId); + if (fromInstance) return fromInstance; return listRegisteredAgents().find((a) => a.id === agentId) ?? null; } @@ -456,8 +479,12 @@ export function resolveAgentSpawn(agent: AcpAgentDescriptor): { }; } - // Re-probe so spawn uses latest PATH resolution - const probed = probeAgentAvailability(agent); + // Re-probe so spawn uses latest PATH resolution. Probe the *driver* + // descriptor (id = driverId) rather than the instance id, so driver-specific + // binary resolution (e.g. Cursor's `agent` disambiguation) still applies; + // the instance only contributes `env`. + const probeBase: AcpAgentDescriptor = agent.driverId ? { ...agent, id: agent.driverId } : agent; + const probed = probeAgentAvailability(probeBase); if (!probed.available) { throw new Error( probed.statusMessage ?? diff --git a/electron/connection-lifecycle.ts b/electron/connection-lifecycle.ts index 99e15b0..df95bac 100644 --- a/electron/connection-lifecycle.ts +++ b/electron/connection-lifecycle.ts @@ -121,7 +121,10 @@ async function terminateChildProcess(child: ChildProcessWithoutNullStreams): Pro export interface LiveConnection { connectionId: string; + /** Spawn routing key: the provider *instance* id (driver id when default). */ agentId: string; + /** The underlying driver/provider id this instance is a configuration of. */ + driverId: string; agentInfoName: string; process: ChildProcessWithoutNullStreams; connection: acp.ClientConnection; @@ -481,6 +484,7 @@ export class ConnectionLifecycle { return { connectionId, agentId: descriptor.id, + driverId: descriptor.driverId ?? descriptor.id, agentInfoName, process: child, connection, diff --git a/electron/db.ts b/electron/db.ts index bb94440..bfd7b00 100644 --- a/electron/db.ts +++ b/electron/db.ts @@ -234,6 +234,34 @@ export function getDb(): DatabaseSync { ); `); + // One row per provider instance (account/configuration). The default + // instance for a driver reuses the driver id as its primary key, so legacy + // threads and selections resolve unchanged; additional accounts get ids like + // `codex-acp:work`. `env_json` holds per-instance environment overrides + // (including credential-root paths such as CODEX_HOME). + db.exec(` + CREATE TABLE IF NOT EXISTS agent_instances ( + id TEXT PRIMARY KEY, + driver_id TEXT NOT NULL, + display_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + env_json TEXT, + config_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_agent_instances_driver ON agent_instances(driver_id); + `); + + // Small key/value store for app-level pointers that don't deserve a column + // (e.g. the last-used provider instance). + db.exec(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + `); + db.exec(` CREATE TABLE IF NOT EXISTS mcp_servers ( id TEXT PRIMARY KEY, @@ -395,3 +423,19 @@ export function getMostRecentAuthUser(): AuthUserRecord | null { .get() as AuthUserRecord | undefined; return row ?? null; } + +export function getAppSetting(key: string): string | null { + const row = getDb().prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as + | { value: string | null } + | undefined; + return row?.value ?? null; +} + +export function setAppSetting(key: string, value: string | null): void { + getDb() + .prepare( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ) + .run(key, value); +} diff --git a/electron/main.ts b/electron/main.ts index cf34509..d5072a8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -42,6 +42,16 @@ import { } from "./db"; import { getThread, listThreads, listThreadsByIds, listProjectThreads } from "./threads"; import { listMcpServers, createMcpServer, updateMcpServer, deleteMcpServer } from "./mcp-servers"; +import { + installAgentInstanceProvider, + listAgentInstancesForRenderer, + listAgentAccountSchemas, + getAgentInstance, + buildInstanceLoginCommand, + createAgentInstance, + updateAgentInstance, + deleteAgentInstance, +} from "./agent-instances"; import { AgentManager } from "./agent"; import { createElectronOsNotifier } from "./os-notifications"; import { WindowVisibilityGate } from "./window-visibility"; @@ -424,6 +434,46 @@ function requireLauncherUpdateManager(): LauncherUpdateManager { return launcherUpdateManager; } +/** + * Launch a provider account's interactive sign-in inside the user's terminal, + * with its isolated credential root exported. Pipper never handles the + * credentials — the CLI runs its own OAuth flow and owns its storage. Returns + * the command so the UI can offer a manual fallback if no terminal opened. + */ +async function launchInstanceLogin( + instanceId: string, +): Promise<{ command: string; opened: boolean }> { + const instance = getAgentInstance(instanceId); + if (!instance) throw new Error(`Unknown account: ${instanceId}`); + const command = buildInstanceLoginCommand(instance); + if (!command) { + throw new Error("This provider signs in with an API key, not a browser login."); + } + try { + if (process.platform === "darwin") { + await execFileAsync("osascript", [ + "-e", + `tell application "Terminal" to do script ${JSON.stringify(command)}`, + "-e", + `tell application "Terminal" to activate`, + ]); + return { command, opened: true }; + } + if (process.platform === "win32") { + await execFileAsync("cmd", ["/c", "start", "", "cmd", "/k", command]); + return { command, opened: true }; + } + const child = spawn("x-terminal-emulator", ["-e", "sh", "-c", command], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return { command, opened: true }; + } catch { + return { command, opened: false }; + } +} + /** * Enter a workspace (the project root or a linked worktree): persist it as * the project's canonical workspace, then restore that workspace's own last @@ -1891,6 +1941,16 @@ function registerIpc(): void { ipcMain.handle("agent:setSelectedAgentIds", (_event, agentIds: string[]) => { setSelectedAgentIds(agentIds); }); + ipcMain.handle("agent:listInstances", () => listAgentInstancesForRenderer()); + ipcMain.handle("agent:getAccountSchemas", () => listAgentAccountSchemas()); + ipcMain.handle("agent:createInstance", (_event, input) => createAgentInstance(input)); + ipcMain.handle("agent:updateInstance", (_event, id: string, input) => + updateAgentInstance(id, input), + ); + ipcMain.handle("agent:deleteInstance", (_event, id: string) => { + deleteAgentInstance(id); + }); + ipcMain.handle("agent:launchInstanceLogin", (_event, id: string) => launchInstanceLogin(id)); ipcMain.handle("agent:closeThreadSession", (_event, threadId: string) => requireAgentManager().closeThreadSession(threadId), ); @@ -2285,6 +2345,9 @@ app.whenReady().then(async () => { logStartupMilestone("database:init:start"); getDb(); logStartupMilestone("database:init:complete"); + // Seed per-driver default instances and wire instance→descriptor resolution + // into the agent registry before anything spawns an agent. + installAgentInstanceProvider(); await prepareBenchmarkLaunchState(); const authUser = getAuthenticatedUserForLaunch(); if (authUser) { diff --git a/electron/preload.ts b/electron/preload.ts index 179ecc4..b3d6897 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -12,6 +12,9 @@ import type { AvailableCommand, SessionConfigOption, AcpAgentDescriptor, + AcpAgentInstance, + AcpAgentInstanceInput, + AgentAccountSchema, AgentProbeResult, SubagentConfig, SubagentRunSnapshot, @@ -321,6 +324,18 @@ const api = { getSelectedAgentIds: (): Promise => ipcRenderer.invoke("agent:getSelectedAgentIds"), setSelectedAgentIds: (agentIds: string[]): Promise => ipcRenderer.invoke("agent:setSelectedAgentIds", agentIds), + listInstances: (): Promise => ipcRenderer.invoke("agent:listInstances"), + getAccountSchemas: (): Promise => + ipcRenderer.invoke("agent:getAccountSchemas"), + createInstance: (input: AcpAgentInstanceInput): Promise => + ipcRenderer.invoke("agent:createInstance", input), + updateInstance: ( + id: string, + input: Partial, + ): Promise => ipcRenderer.invoke("agent:updateInstance", id, input), + deleteInstance: (id: string): Promise => ipcRenderer.invoke("agent:deleteInstance", id), + launchInstanceLogin: (id: string): Promise<{ command: string; opened: boolean }> => + ipcRenderer.invoke("agent:launchInstanceLogin", id), setConfigOption: (configId: string, value: string | boolean): Promise => ipcRenderer.invoke("agent:setConfigOption", configId, value), respondToPermission: (response: { diff --git a/electron/remote-server.ts b/electron/remote-server.ts index 3042a23..991024e 100644 --- a/electron/remote-server.ts +++ b/electron/remote-server.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import type { AgentManager } from "./agent-connection-manager.ts"; import { listProjects, getProject } from "./projects.ts"; import { listRegisteredAgents } from "./agents/registry.ts"; +import { listAgentInstanceDescriptors } from "./agent-instances.ts"; import { getThread, listThreads } from "./threads.ts"; import { createWorktree, gitBinary, removeWorktree } from "./worktree-manager.ts"; import type { @@ -331,9 +332,17 @@ export class RemoteServer { return send(res, 200, { projects }); } if (req.method === "GET" && path === "/api/remote/models") { - const models: RemoteModel[] = listRegisteredAgents().map((a) => ({ + // Offer provider instances (accounts), not just drivers, so a phone can + // route a task to a specific account. Default instances reuse the + // driver id, so single-account setups see exactly the same list. + // `provider` groups accounts under their driver on the phone. + const driverNames = new Map( + listRegisteredAgents().map((driver) => [driver.id, driver.displayName ?? driver.name]), + ); + const models: RemoteModel[] = listAgentInstanceDescriptors().map((a) => ({ id: a.id, name: a.displayName ?? a.name ?? a.id, + provider: driverNames.get(a.driverId ?? a.id) ?? a.driverId ?? a.id, })); return send(res, 200, { models }); } @@ -400,10 +409,11 @@ export class RemoteServer { console.log( `[Remote] new phone thread project=${project.id} worktree=${worktreePath ?? ""} promptLen=${body.prompt.length}`, ); - // modelId from the phone is an *agent* id (listRegisteredAgents). - // Use it to pick the connection, but never as a model name — the - // agent's own default model applies (e.g. antigravity has no implicit - // default; the user's desktop default is used). + // modelId from the phone is a provider *instance* id + // (listAgentInstanceDescriptors; driver id when default). Use it to pick + // the connection, but never as a model name — the agent's own default + // model applies (e.g. antigravity has no implicit default; the user's + // desktop default is used). try { const thread = await am.createThread( project.id, diff --git a/src/components/agent-accounts-settings.tsx b/src/components/agent-accounts-settings.tsx new file mode 100644 index 0000000..c273eec --- /dev/null +++ b/src/components/agent-accounts-settings.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from "react"; +import { LogIn, Plus, Trash2, Users } from "lucide-react"; +import { useAgentInstancesStore } from "@/store/agent-instances-store"; +import type { AcpAgentInstance, AgentAccountSchema } from "../../contracts/acp.ts"; + +function envHint(instance: AcpAgentInstance, schema: AgentAccountSchema): string | null { + const names = (instance.env ?? []).map((entry) => entry.name); + if (names.length) return names.join(", "); + if (instance.id === instance.driverId) return "Uses your existing CLI login"; + if (schema.authEnvVar) return schema.authEnvVar; + return null; +} + +function AccountRow({ + instance, + schema, + onRemove, + onSignIn, +}: { + instance: AcpAgentInstance; + schema: AgentAccountSchema; + onRemove: (id: string) => void; + onSignIn: (id: string) => void; +}) { + const isDefault = instance.id === instance.driverId; + const hint = envHint(instance, schema); + return ( +
+
+
+ {instance.displayName} + {isDefault ? ( + + DEFAULT + + ) : null} +
+ {hint ? ( +
{hint}
+ ) : null} +
+ {schema.supportsLogin ? ( + + ) : null} + {isDefault ? null : ( + + )} +
+ ); +} + +function AddAccountForm({ + schema, + onSubmit, + onCancel, +}: { + schema: AgentAccountSchema; + onSubmit: (input: { displayName: string; secret?: string }) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [secret, setSecret] = useState(""); + const canSubmit = name.trim().length > 0 && (!schema.authEnvVar || secret.trim().length > 0); + + return ( +
+ setName(event.target.value)} + placeholder="Account name (e.g. Work)" + className="h-8 rounded-lg border border-border/70 bg-surface-1 px-2.5 text-[12px] text-foreground outline-none focus:border-border" + /> + {schema.authEnvVar ? ( + setSecret(event.target.value)} + placeholder={schema.authEnvVar} + type="password" + className="h-8 rounded-lg border border-border/70 bg-surface-1 px-2.5 text-[12px] text-foreground outline-none focus:border-border" + /> + ) : null} +
+ + +
+
+ ); +} + +/** + * Settings section for managing provider accounts. Lets the user add a second + * account for a driver (isolated via its credential-root env var) without + * touching the default ambient login. + */ +export function AgentAccountsSettings() { + const { instances, schemas, error, load, create, remove, launchLogin } = useAgentInstancesStore(); + const [addingDriverId, setAddingDriverId] = useState(null); + const [notice, setNotice] = useState(null); + + useEffect(() => { + void load(); + }, [load]); + + const multiAccountSchemas = useMemo( + () => schemas.filter((schema) => schema.supportsMultipleAccounts), + [schemas], + ); + + const handleRemove = async (id: string) => { + try { + await remove(id); + } catch { + // Store surfaces the error. + } + }; + + const handleSignIn = async (id: string) => { + try { + const result = await launchLogin(id); + if (result.opened) { + setNotice("A terminal opened — finish signing in there, then restart Pipper."); + } else { + try { + await navigator.clipboard?.writeText(result.command); + setNotice(`Sign-in command copied to clipboard: ${result.command}`); + } catch { + setNotice(`Run this in your terminal: ${result.command}`); + } + } + } catch (err) { + setNotice(err instanceof Error ? err.message : "Could not start sign-in"); + } + }; + + const handleAdd = async ( + schema: AgentAccountSchema, + input: { displayName: string; secret?: string }, + ) => { + try { + await create({ + driverId: schema.driverId, + displayName: input.displayName, + env: + schema.authEnvVar && input.secret + ? [{ name: schema.authEnvVar, value: input.secret, sensitive: true }] + : undefined, + }); + setAddingDriverId(null); + setNotice( + schema.supportsLogin + ? "Account added. Click Sign in to authenticate it." + : "Account added.", + ); + } catch { + // Store surfaces the error. + } + }; + + return ( +
+ {error ?
{error}
: null} + {notice ? ( +
{notice}
+ ) : null} + {multiAccountSchemas.map((schema, index) => { + const accounts = instances.filter((instance) => instance.driverId === schema.driverId); + return ( +
+ {index > 0 ?
: null} +
+
+ +
+
+
{schema.displayName}
+
+ {accounts.length > 1 + ? `${accounts.length} accounts connected` + : "Add a second account for this provider"} +
+
+ +
+
+ {accounts.map((instance) => ( + + ))} + {addingDriverId === schema.driverId ? ( + void handleAdd(schema, input)} + onCancel={() => setAddingDriverId(null)} + /> + ) : null} +
+
+ ); + })} + {multiAccountSchemas.length === 0 ? ( +
+ No providers with multi-account support are available. +
+ ) : null} +
+ ); +} diff --git a/src/components/agent-panel.tsx b/src/components/agent-panel.tsx index 38c8cd7..0ce3d9c 100644 --- a/src/components/agent-panel.tsx +++ b/src/components/agent-panel.tsx @@ -53,6 +53,7 @@ import { AgentSlashCommandMenu } from "@/components/agent-slash-command-menu"; import { AgentContinueMenu } from "@/components/agent-continue-menu"; import { AgentQuestionCard, AgentQuestionDock } from "@/components/agent-question"; import { cn } from "@/lib/utils"; +import { isInstanceSelected } from "@/lib/agent-selection"; import { beginRendererInteraction } from "@/lib/monitor-runtime-observer"; import { toast } from "@/components/ui/toast"; import type { AgentPanelSnapshot } from "@/store/agent-store"; @@ -767,7 +768,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { // Prefer the currently connected agent when it is in the user's pool. const registry = useAgentRegistryStore.getState(); const availableAgents = registry.agents.filter( - (a) => registry.selectedAgentIds.includes(a.id) && a.available !== false, + (a) => isInstanceSelected(a, registry.selectedAgentIds) && a.available !== false, ); const pool = availableAgents.length > 0 @@ -809,7 +810,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { const pool = registry.agents.filter( (a) => (registry.selectedAgentIds.length === 0 || - registry.selectedAgentIds.includes(a.id)) && + isInstanceSelected(a, registry.selectedAgentIds)) && a.available !== false, ); setDraftAgent(pool[0]?.id ?? null); @@ -1998,7 +1999,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { // late or selectedAgentIds is still empty). const draftAgentItems = useMemo(() => { const available = registryAgents.filter((a) => a.available !== false); - const selected = available.filter((a) => selectedAgentIds.includes(a.id)); + const selected = available.filter((a) => isInstanceSelected(a, selectedAgentIds)); const pool = selected.length > 0 ? selected : available; return pool.map((a) => ({ id: a.id, diff --git a/src/components/agent-selector.tsx b/src/components/agent-selector.tsx index c7dc0c3..12b9896 100644 --- a/src/components/agent-selector.tsx +++ b/src/components/agent-selector.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/card"; import { createProviderLogoIcon } from "@/components/provider-logos"; import { cn } from "@/lib/utils"; +import { isDefaultInstance, isInstanceSelected } from "@/lib/agent-selection"; import type { AcpAgentDescriptor, AgentProbeResult } from "../../contracts/acp.ts"; /** @@ -143,13 +144,16 @@ export function AgentSelector({ } const visibleAgents = agents.filter((agent) => { + // Onboarding is provider-level: show one card per driver. Extra accounts + // are added later from Settings, not during first-run setup. + if (!isDefaultInstance(agent)) return false; if (agent.installKind !== "mock") return true; const anyReady = agents.some((a) => a.available && a.installKind !== "mock"); return !anyReady; }); const selectedAgentNames = agents - .filter((a) => selectedAgentIds.includes(a.id)) + .filter((a) => isInstanceSelected(a, selectedAgentIds)) .map((a) => a.displayName); return ( @@ -169,7 +173,7 @@ export function AgentSelector({ { await toggleAgent(agent.id); const next = useAgentRegistryStore.getState().selectedAgentIds; @@ -338,7 +342,8 @@ function AgentSetupCard({ result.status === "needs-auth" ? `Sign in required for ${descriptor.displayName}. Retry after authenticating` : `Retry ${descriptor.displayName}`; - const guideUrl = status === "ready" ? null : setupGuideUrl(descriptor.id); + // Instances carry `driverId`; the setup guide is keyed by driver. + const guideUrl = status === "ready" ? null : setupGuideUrl(descriptor.driverId ?? descriptor.id); const openSetupGuide = async () => { if (!guideUrl || !window.omni?.shell?.openExternal) return; diff --git a/src/electron.d.ts b/src/electron.d.ts index 45ab842..0ff7dde 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -3,6 +3,9 @@ import type { GitBranch, Worktree, WorktreeSetupProgress } from "../../contracts import type { OpenTabsState, Thread, ThreadPage } from "../../contracts/threads.ts"; import type { AcpAgentDescriptor, + AcpAgentInstance, + AcpAgentInstanceInput, + AgentAccountSchema, AcpBridgeEvent, AcpPromptInput, AcpReplacePromptInput, @@ -212,6 +215,15 @@ declare global { ) => Promise; getSelectedAgentIds: () => Promise; setSelectedAgentIds: (agentIds: string[]) => Promise; + listInstances: () => Promise; + getAccountSchemas: () => Promise; + createInstance: (input: AcpAgentInstanceInput) => Promise; + updateInstance: ( + id: string, + input: Partial, + ) => Promise; + deleteInstance: (id: string) => Promise; + launchInstanceLogin: (id: string) => Promise<{ command: string; opened: boolean }>; setConfigOption: ( configId: string, value: string | boolean, diff --git a/src/lib/agent-selection.ts b/src/lib/agent-selection.ts new file mode 100644 index 0000000..f527f6f --- /dev/null +++ b/src/lib/agent-selection.ts @@ -0,0 +1,24 @@ +import type { AcpAgentDescriptor } from "../../contracts/acp.ts"; + +/** + * True when a descriptor is a driver's default instance (id === driver id). + * Onboarding operates at this level: users pick providers, then add extra + * accounts later from Settings. + */ +export function isDefaultInstance(agent: AcpAgentDescriptor): boolean { + return (agent.driverId ?? agent.id) === agent.id; +} + +/** + * An agent (possibly a non-default account) counts as selected when either its + * own instance id or its provider's driver id is in the selected set. This + * keeps onboarding's provider-level selection meaningful for accounts the user + * adds afterward. + */ +export function isInstanceSelected( + agent: AcpAgentDescriptor, + selectedIds: readonly string[], +): boolean { + if (selectedIds.includes(agent.id)) return true; + return agent.driverId != null && selectedIds.includes(agent.driverId); +} diff --git a/src/remote/App.tsx b/src/remote/App.tsx index f15810f..d4bcda0 100644 --- a/src/remote/App.tsx +++ b/src/remote/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { List, PaperPlaneTilt, Plus, QrCode } from "@phosphor-icons/react"; import { PhoneMarkdown } from "./markdown.tsx"; +import { groupModelsByProvider } from "./model-groups.ts"; import type { RemoteModel, RemoteProject, @@ -387,11 +388,23 @@ export function RemoteApp() { aria-label="Model" > - {models.map((m) => ( - - ))} + {groupModelsByProvider(models).map((group) => + group.provider ? ( + + {group.models.map((m) => ( + + ))} + + ) : ( + group.models.map((m) => ( + + )) + ), + )}

diff --git a/src/remote/model-groups.test.ts b/src/remote/model-groups.test.ts new file mode 100644 index 0000000..14c5e9d --- /dev/null +++ b/src/remote/model-groups.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { groupModelsByProvider } from "./model-groups.ts"; + +describe("groupModelsByProvider", () => { + test("groups accounts under their provider, preserving order", () => { + const groups = groupModelsByProvider([ + { id: "codex-acp", name: "Codex", provider: "Codex" }, + { id: "codex-acp:work", name: "Work", provider: "Codex" }, + { id: "claude-agent-acp", name: "Claude", provider: "Claude" }, + ]); + expect(groups).toHaveLength(2); + expect(groups[0]).toEqual({ + provider: "Codex", + models: [ + { id: "codex-acp", name: "Codex", provider: "Codex" }, + { id: "codex-acp:work", name: "Work", provider: "Codex" }, + ], + }); + expect(groups[1].provider).toBe("Claude"); + }); + + test("keeps unlabeled models in an ungrouped bucket", () => { + const groups = groupModelsByProvider([{ id: "x", name: "X" }]); + expect(groups).toEqual([{ provider: null, models: [{ id: "x", name: "X" }] }]); + }); + + test("returns an empty list for no models", () => { + expect(groupModelsByProvider([])).toEqual([]); + }); +}); diff --git a/src/remote/model-groups.ts b/src/remote/model-groups.ts new file mode 100644 index 0000000..f2c5550 --- /dev/null +++ b/src/remote/model-groups.ts @@ -0,0 +1,22 @@ +import type { RemoteModel } from "../../contracts/remote.ts"; + +/** + * Group accounts under their provider for the phone's model picker. A keyed + * map keeps insertion order; a null provider means the model had no provider + * label and should render ungrouped. + */ +export function groupModelsByProvider( + models: RemoteModel[], +): Array<{ provider: string | null; models: RemoteModel[] }> { + const groups = new Map(); + for (const model of models) { + const key = model.provider ?? ""; + const list = groups.get(key) ?? []; + list.push(model); + groups.set(key, list); + } + return [...groups.entries()].map(([provider, items]) => ({ + provider: provider || null, + models: items, + })); +} diff --git a/src/settings/app.tsx b/src/settings/app.tsx index c21a7f6..985931b 100644 --- a/src/settings/app.tsx +++ b/src/settings/app.tsx @@ -1,4 +1,5 @@ import { Keyboard, Monitor, Moon, Sun } from "lucide-react"; +import { AgentAccountsSettings } from "@/components/agent-accounts-settings"; import { RemoteAccessSettings } from "@/components/remote-access-settings"; import { SleeplessControl } from "@/components/sleepless-control"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -146,6 +147,22 @@ export function SettingsApp() {

+
+

+ Accounts +

+
+ +
+

+ Each account runs in its own isolated configuration directory, so you can use a work + and personal subscription side by side. +

+
+

Promise; + create: (input: AcpAgentInstanceInput) => Promise; + update: (id: string, input: Partial) => Promise; + remove: (id: string) => Promise; + launchLogin: (id: string) => Promise<{ command: string; opened: boolean }>; +} + +/** + * Provider accounts/instances. One default instance per driver (id === driver + * id) always exists; additional accounts are user-created and carry their own + * environment (e.g. an isolated CODEX_HOME). + */ +export const useAgentInstancesStore = create((set, get) => ({ + instances: [], + schemas: [], + loading: false, + error: null, + + load: async () => { + if (!window.omni?.agent?.listInstances) return; + set({ loading: true, error: null }); + try { + const [instances, schemas] = await Promise.all([ + window.omni.agent.listInstances(), + window.omni.agent.getAccountSchemas?.() ?? Promise.resolve([]), + ]); + set({ instances, schemas, loading: false }); + } catch (err) { + set({ + loading: false, + error: err instanceof Error ? err.message : "Failed to load accounts", + }); + } + }, + + create: async (input) => { + const created = await window.omni.agent.createInstance(input); + await get().load(); + return created; + }, + + update: async (id, input) => { + await window.omni.agent.updateInstance(id, input); + await get().load(); + }, + + remove: async (id) => { + await window.omni.agent.deleteInstance(id); + await get().load(); + }, + + launchLogin: async (id) => window.omni.agent.launchInstanceLogin(id), +})); From 889da78127d1b11fa6e7320d14942dfc24ee4d14 Mon Sep 17 00:00:00 2001 From: Harshith Pasupuleti Date: Sat, 26 Sep 2026 12:27:43 +0530 Subject: [PATCH 2/8] Address multi-account review feedback - Redact the create/update instance IPC responses so decrypted secrets never reach the renderer - Refuse to persist sensitive values when safeStorage is unavailable instead of silently writing plaintext - Strip ambient provider credentials for isolated accounts so a child cannot fall back to the machine's default login - Reconcile removed accounts: close their connection, drop cached sessions, re-point threads at the driver default, reset the preferred pointer - Preserve stored secrets when a redacted (empty) value round-trips - Validate driverId and reject unknown drivers - Keep enabled-instance semantics: no driver fallback once instances exist, so disabled accounts stay disabled - Broadcast account changes so the main window's picker refreshes - Set and rethrow store errors for account mutations - Build a Windows-compatible login command and await Linux terminal spawn --- contracts/acp.ts | 7 +++ electron/agent-connection-manager.ts | 39 ++++++++++-- electron/agent-instances.test.ts | 90 +++++++++++++++++++++++++- electron/agent-instances.ts | 94 ++++++++++++++++++++++++---- electron/agents/registry.ts | 8 +++ electron/connection-lifecycle.ts | 19 ++++++ electron/main.ts | 39 +++++++++--- electron/preload.ts | 7 +++ src/electron.d.ts | 1 + src/store/agent-instances-store.ts | 38 ++++++++--- src/store/agent-registry-store.ts | 8 +++ 11 files changed, 313 insertions(+), 37 deletions(-) diff --git a/contracts/acp.ts b/contracts/acp.ts index d76689b..ac1a403 100644 --- a/contracts/acp.ts +++ b/contracts/acp.ts @@ -55,6 +55,13 @@ export interface AcpAgentDescriptor { command: string; args: string[]; env?: Record; + /** + * Names to delete from the child's environment before spawn. Used to drop + * ambient provider credentials (e.g. OPENAI_API_KEY) for an isolated account + * so its process cannot silently authenticate as the machine's default + * account. + */ + unsetEnv?: string[]; /** Optional icon key for tab indicators. */ icon?: string; /** Short description for onboarding. */ diff --git a/electron/agent-connection-manager.ts b/electron/agent-connection-manager.ts index aa933fb..fe2ffa6 100644 --- a/electron/agent-connection-manager.ts +++ b/electron/agent-connection-manager.ts @@ -39,7 +39,7 @@ import { import { normalizeWorkspacePath, pickWorkspaceThread } from "../contracts/workspace-scope.ts"; import { isLiveWorktree } from "./worktree-manager.ts"; import { getAgentDescriptor, getDefaultAgentId, listRegisteredAgents } from "./agents/registry.ts"; -import { listAgentInstanceDescriptors } from "./agent-instances.ts"; +import { listAgentInstanceDescriptors, hasAgentInstances } from "./agent-instances.ts"; import { ACP_SWITCH_PHASE_TIMEOUT_MS, ConnectionLifecycle, @@ -393,11 +393,38 @@ export class AgentConnectionManager { } listAgents(): AcpAgentDescriptor[] { - // Always re-probe PATH so onboarding reflects install state. When accounts - // are configured, surface each instance (default instances reuse the driver - // id, so this is a no-op for single-account users). - const instances = listAgentInstanceDescriptors(); - return instances.length ? instances : listRegisteredAgents(); + // Once instance storage is seeded, it is the source of truth: return the + // enabled instances even when that list is empty (all accounts disabled). + // Only fall back to the raw driver catalog in the uninitialized/legacy + // state, so a disabled default account is never re-exposed as selectable. + if (hasAgentInstances()) return listAgentInstanceDescriptors(); + return listRegisteredAgents(); + } + + /** + * Reconcile a removed account: drop its cached sessions, close its process, + * and move the preferred pointer off it. Thread rows are re-pointed at the + * driver's default instance by `deleteAgentInstance`. + */ + async removeAgentInstance(instanceId: string): Promise { + // Collect first: removing entries while iterating the registry would skip + // siblings when several threads share the account. + const ownedThreadIds: string[] = []; + for (const [threadId, runtime] of this.sessions.entries()) { + if (runtime.agentId === instanceId) ownedThreadIds.push(threadId); + } + for (const threadId of ownedThreadIds) { + const runtime = this.sessions.get(threadId); + if (!runtime) continue; + this.permissions.cancelForSession(runtime.agentSessionId); + this.prompts.cancelInFlight(threadId, "account removed"); + this.sessions.remove(threadId); + } + await this.lifecycle.close(instanceId); + if (this.preferredAgentId === instanceId) { + this.preferredAgentId = getDefaultAgentId(); + persistPreferredAgentId(this.preferredAgentId); + } } async getModelCatalogs(): Promise< diff --git a/electron/agent-instances.test.ts b/electron/agent-instances.test.ts index 137d7bd..f54ff4f 100644 --- a/electron/agent-instances.test.ts +++ b/electron/agent-instances.test.ts @@ -4,12 +4,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AcpAgentDescriptor } from "../contracts/acp.ts"; +const safeStorageState = vi.hoisted(() => ({ available: true })); + vi.mock("electron", () => ({ app: { getPath: () => process.env.PIPPER_LIBRARY_PATH ?? process.env.TMPDIR ?? "/tmp" }, // Reversible stand-in so the encryption round-trip is observable without a - // real OS keychain. + // real OS keychain. `available` is togglable to exercise the refusal path. safeStorage: { - isEncryptionAvailable: () => true, + isEncryptionAvailable: () => safeStorageState.available, encryptString: (value: string) => Buffer.from(`sealed:${value}`, "utf8"), decryptString: (buffer: Buffer) => buffer.toString("utf8").replace(/^sealed:/, ""), }, @@ -34,6 +36,13 @@ vi.mock("./agents/registry.ts", () => { command: "npx", args: [], }, + { + id: "cursor-acp", + name: "cursor", + displayName: "Cursor", + command: "agent", + args: ["acp"], + }, ]; return { listRegisteredAgents: () => drivers.map((driver) => ({ ...driver })), @@ -46,6 +55,7 @@ let root: string | null = null; beforeEach(() => { vi.resetModules(); + safeStorageState.available = true; root = mkdtempSync(join(tmpdir(), "pipper-agent-instances-")); process.env.PIPPER_LIBRARY_PATH = root; }); @@ -174,4 +184,80 @@ describe("agent instances", () => { // API-key providers have no interactive login. expect(mod.buildInstanceLoginCommand({ ...created, driverId: "cursor-acp" })).toBeNull(); }); + + test("builds a Windows-compatible login command", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const command = mod.buildInstanceLoginCommand(created, "win32"); + expect(command).toMatch(/^set "CODEX_HOME=.*" && codex login$/); + }); + + test("rejects an unknown driverId", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + expect(() => mod.createAgentInstance({ driverId: "nope", displayName: "X" })).toThrow( + /Unknown driverId/, + ); + }); + + test("refuses to store a secret when encryption is unavailable", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + safeStorageState.available = false; + expect(() => + mod.createAgentInstance({ + driverId: "cursor-acp", + displayName: "Work", + env: [{ name: "CURSOR_API_KEY", value: "plaintext", sensitive: true }], + }), + ).toThrow(/plaintext/); + }); + + test("preserves a stored secret when a redacted empty value round-trips", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "cursor-acp", + displayName: "Work", + env: [{ name: "CURSOR_API_KEY", value: "keep-me", sensitive: true }], + }); + // Renderer got "", then sends the account back while editing another field. + mod.updateAgentInstance(created.id, { + displayName: "Work Renamed", + env: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }], + }); + const stored = mod.getAgentInstance(created.id); + expect(stored?.displayName).toBe("Work Renamed"); + expect((stored?.env ?? []).find((e) => e.name === "CURSOR_API_KEY")?.value).toBe("keep-me"); + }); + + test("strips ambient credentials for a non-default account", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const descriptor = mod.resolveAgentInstanceDescriptor("codex-acp:work"); + expect(descriptor?.unsetEnv).toContain("OPENAI_API_KEY"); + expect(descriptor?.unsetEnv).toContain("CODEX_API_KEY"); + // The default instance keeps the ambient environment. + expect(mod.resolveAgentInstanceDescriptor("codex-acp")?.unsetEnv).toBeUndefined(); + }); + + test("re-points threads at the driver default when an account is removed", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const db = (await import("./db.ts")).getDb(); + db.prepare("INSERT INTO projects (id, path, name) VALUES (?, ?, ?)").run("p1", "/repo", "Repo"); + db.prepare( + "INSERT INTO threads (id, project_id, agent_id, agent_session_id) VALUES (?, ?, ?, ?)", + ).run("t1", "p1", created.id, "s1"); + + mod.deleteAgentInstance(created.id); + + const row = db.prepare("SELECT agent_id FROM threads WHERE id = ?").get("t1") as { + agent_id: string; + }; + expect(row.agent_id).toBe("codex-acp"); + }); }); diff --git a/electron/agent-instances.ts b/electron/agent-instances.ts index 5a4b878..0ea0a28 100644 --- a/electron/agent-instances.ts +++ b/electron/agent-instances.ts @@ -44,6 +44,22 @@ export const AUTH_ENV_BY_DRIVER: Record = { "gemini-acp": "GEMINI_API_KEY", }; +/** + * Ambient credentials a driver may pick up from the parent environment. For a + * non-default (isolated) account these are removed before spawn so the child + * cannot fall back to the machine's default login instead of the account the + * user configured. + */ +const CREDENTIAL_ENV_BY_DRIVER: Record = { + "codex-acp": ["OPENAI_API_KEY", "CODEX_API_KEY"], + "claude-agent-acp": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + "grok-acp": ["XAI_API_KEY"], + "copilot-acp": ["GH_TOKEN", "GITHUB_TOKEN"], + "gemini-acp": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + "cursor-acp": ["CURSOR_API_KEY"], + "antigravity-acp": ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"], +}; + /** * Interactive sign-in command per driver, run once per account inside the * user's terminal (never in-process) so the CLI performs its own OAuth flow @@ -66,15 +82,20 @@ function shellQuote(value: string): string { /** * The shell command a user runs to sign an account in, with its credential * root exported inline. Returns null when the driver has no interactive login - * (API-key providers) so the UI can prompt for a key instead. + * (API-key providers) so the UI can prompt for a key instead. Windows shells + * don't accept the POSIX `VAR=value cmd` prefix, so build `set "VAR=value" && cmd`. */ -export function buildInstanceLoginCommand(instance: AcpAgentInstance): string | null { +export function buildInstanceLoginCommand( + instance: AcpAgentInstance, + platform: NodeJS.Platform = process.platform, +): string | null { const login = LOGIN_COMMAND_BY_DRIVER[instance.driverId]; if (!login) return null; const profileVar = PROFILE_ENV_BY_DRIVER[instance.driverId]; if (!profileVar) return login; const entry = (instance.env ?? []).find((item) => item.name === profileVar); if (!entry?.value) return login; + if (platform === "win32") return `set "${profileVar}=${entry.value}" && ${login}`; return `${profileVar}=${shellQuote(entry.value)} ${login}`; } @@ -96,14 +117,19 @@ const ENC_PREFIX = "enc:"; function encryptSecret(value: string): string { if (!value) return value; + let encrypted: Buffer | null = null; try { - if (safeStorage?.isEncryptionAvailable()) { - return ENC_PREFIX + safeStorage.encryptString(value).toString("base64"); - } + if (safeStorage?.isEncryptionAvailable()) encrypted = safeStorage.encryptString(value); } catch { - // Fall through to plaintext (e.g. unsupported platform). + encrypted = null; + } + if (!encrypted) { + // Never silently downgrade to plaintext: refuse the write instead. + throw new Error( + "Secure credential storage is unavailable on this device; refusing to save the secret in plaintext.", + ); } - return value; + return ENC_PREFIX + encrypted.toString("base64"); } function decryptSecret(value: string): string { @@ -212,6 +238,9 @@ export function suggestProfileEnv(driverId: string, instanceId: string): AcpAgen export function createAgentInstance(input: AcpAgentInstanceInput): AcpAgentInstance { const driverId = input.driverId.trim(); if (!driverId) throw new Error("driverId is required"); + if (!listRegisteredAgents().some((driver) => driver.id === driverId)) { + throw new Error(`Unknown driverId: ${driverId}`); + } const displayName = input.displayName.trim() || driverId; const id = (input.id?.trim() || instanceIdFor(driverId, displayName)).trim(); // Additional accounts default to an isolated credential root so two logins @@ -262,7 +291,16 @@ export function updateAgentInstance( ...existing, displayName: input.displayName?.trim() || existing.displayName, enabled: input.enabled ?? existing.enabled, - env: input.env ?? existing.env, + // A redacted list response carries sensitive values as ""; treat an empty + // sensitive value as "unchanged" so a round-trip edit can't erase the + // stored credential. Omitting the entry still removes it. + env: input.env + ? input.env.map((entry) => { + if (!entry.sensitive || entry.value) return entry; + const prior = existing.env?.find((candidate) => candidate.name === entry.name); + return prior ? { ...entry, value: prior.value } : entry; + }) + : existing.env, config: input.config ?? existing.config, updatedAt: Date.now(), }; @@ -283,13 +321,35 @@ export function updateAgentInstance( } export function deleteAgentInstance(id: string): void { + const existing = getAgentInstance(id); + if (!existing) return; // The default instance (id === driver id) is structurally required: it backs // the driver's ambient login and legacy thread rows. - const existing = getAgentInstance(id); - if (existing && existing.id === existing.driverId) { + if (existing.id === existing.driverId) { throw new Error("Cannot delete a driver's default instance"); } - getDb().prepare("DELETE FROM agent_instances WHERE id = ?").run(id); + const db = getDb(); + db.exec("BEGIN IMMEDIATE;"); + try { + // Re-point threads/snapshots that referenced the removed account at the + // driver's default instance so they stay resolvable (and become explicit + // rather than silently falling back to whatever resolves first). + db.prepare("UPDATE threads SET agent_id = ? WHERE agent_id = ?").run(existing.driverId, id); + db.prepare("UPDATE thread_snapshots SET agent_id = ? WHERE agent_id = ?").run( + existing.driverId, + id, + ); + db.prepare("DELETE FROM agent_instances WHERE id = ?").run(id); + db.exec("COMMIT;"); + } catch (error) { + db.exec("ROLLBACK;"); + throw error; + } +} + +/** True once the instance table has been seeded (initialized state). */ +export function hasAgentInstances(): boolean { + return Boolean(getDb().prepare("SELECT 1 FROM agent_instances LIMIT 1").get()); } function driverDescriptorById(): Map { @@ -301,13 +361,23 @@ function materialize(instance: AcpAgentInstance, driver: AcpAgentDescriptor): Ac for (const entry of instance.env ?? []) { if (entry.name) env[entry.name] = entry.value; } - return { + const descriptor: AcpAgentDescriptor = { ...driver, id: instance.id, driverId: instance.driverId, displayName: instance.displayName, env, }; + // Isolated accounts must not inherit the machine's ambient provider keys; + // strip them unless the account explicitly sets that same variable. + if (instance.id !== instance.driverId) { + const setNames = new Set((instance.env ?? []).map((entry) => entry.name)); + const unsetEnv = (CREDENTIAL_ENV_BY_DRIVER[instance.driverId] ?? []).filter( + (name) => !setNames.has(name), + ); + if (unsetEnv.length) descriptor.unsetEnv = unsetEnv; + } + return descriptor; } /** Resolve an instance id to a spawnable descriptor with merged env. */ diff --git a/electron/agents/registry.ts b/electron/agents/registry.ts index b8020c2..84e7767 100644 --- a/electron/agents/registry.ts +++ b/electron/agents/registry.ts @@ -443,6 +443,11 @@ export function listRegisteredAgents(): AcpAgentDescriptor[] { export function getAgentDescriptor(agentId: string): AcpAgentDescriptor | null { const fromInstance = instanceDescriptorProvider?.(agentId); if (fromInstance) return fromInstance; + // Once instance storage is configured, a miss means the instance is unknown + // or disabled — do NOT fall back to the driver (that would re-enable a + // disabled default account). The driver fallback is only for the + // uninitialized/legacy state with no provider installed. + if (instanceDescriptorProvider) return null; return listRegisteredAgents().find((a) => a.id === agentId) ?? null; } @@ -467,6 +472,9 @@ export function resolveAgentSpawn(agent: AcpAgentDescriptor): { env: Record; } { const env = { ...process.env, ...agent.env } as Record; + // Drop ambient provider credentials for isolated accounts so the child can't + // authenticate as the machine's default login instead of the chosen account. + for (const name of agent.unsetEnv ?? []) delete env[name]; if (agent.id === "pipper-mock" || agent.installKind === "mock") { const mockPath = join(registryDir, "mock-agent.mjs"); diff --git a/electron/connection-lifecycle.ts b/electron/connection-lifecycle.ts index df95bac..257bf82 100644 --- a/electron/connection-lifecycle.ts +++ b/electron/connection-lifecycle.ts @@ -278,6 +278,25 @@ export class ConnectionLifecycle { ); } + /** + * Close and forget one instance's transport (e.g. its account was removed), + * without touching other instances of the same driver. + */ + async close(agentId: string): Promise { + const live = this.connections.get(agentId); + if (!live) return; + this.connections.delete(agentId); + this.intentionalConnectionIds.add(live.connectionId); + if (this.activeConnection === live) this.activeConnection = null; + try { + live.connection.close(); + } catch { + // ignore + } + await terminateChildProcess(live.process); + this.deps.invalidateAgentSessions(agentId); + } + private async spawnAndInitialize(descriptor: AcpAgentDescriptor): Promise { const { command, args, env } = resolveAgentSpawn(descriptor); const useShell = process.platform === "win32" && /\.cmd$/i.test(command); diff --git a/electron/main.ts b/electron/main.ts index d5072a8..97ee07f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -47,6 +47,7 @@ import { listAgentInstancesForRenderer, listAgentAccountSchemas, getAgentInstance, + redactInstance, buildInstanceLoginCommand, createAgentInstance, updateAgentInstance, @@ -463,12 +464,19 @@ async function launchInstanceLogin( await execFileAsync("cmd", ["/c", "start", "", "cmd", "/k", command]); return { command, opened: true }; } - const child = spawn("x-terminal-emulator", ["-e", "sh", "-c", command], { - detached: true, - stdio: "ignore", + // Linux: `spawn` reports a missing terminal via an async 'error' event, not + // a throw, so wait for spawn/error before claiming the terminal opened. + return await new Promise<{ command: string; opened: boolean }>((resolve) => { + const child = spawn("x-terminal-emulator", ["-e", "sh", "-c", command], { + detached: true, + stdio: "ignore", + }); + child.once("spawn", () => { + child.unref(); + resolve({ command, opened: true }); + }); + child.once("error", () => resolve({ command, opened: false })); }); - child.unref(); - return { command, opened: true }; } catch { return { command, opened: false }; } @@ -1943,12 +1951,23 @@ function registerIpc(): void { }); ipcMain.handle("agent:listInstances", () => listAgentInstancesForRenderer()); ipcMain.handle("agent:getAccountSchemas", () => listAgentAccountSchemas()); - ipcMain.handle("agent:createInstance", (_event, input) => createAgentInstance(input)); - ipcMain.handle("agent:updateInstance", (_event, id: string, input) => - updateAgentInstance(id, input), - ); - ipcMain.handle("agent:deleteInstance", (_event, id: string) => { + ipcMain.handle("agent:createInstance", (_event, input) => { + const created = createAgentInstance(input); + broadcastToWindows("agent:instancesChanged", {}); + // Never ship decrypted secrets back to the renderer. + return redactInstance(created); + }); + ipcMain.handle("agent:updateInstance", (_event, id: string, input) => { + const updated = updateAgentInstance(id, input); + broadcastToWindows("agent:instancesChanged", {}); + return updated ? redactInstance(updated) : null; + }); + ipcMain.handle("agent:deleteInstance", async (_event, id: string) => { + // Reconcile live sessions/connection and the preferred pointer, then + // delete (which re-points any threads at the driver's default instance). + await agentManager?.removeAgentInstance(id); deleteAgentInstance(id); + broadcastToWindows("agent:instancesChanged", {}); }); ipcMain.handle("agent:launchInstanceLogin", (_event, id: string) => launchInstanceLogin(id)); ipcMain.handle("agent:closeThreadSession", (_event, threadId: string) => diff --git a/electron/preload.ts b/electron/preload.ts index b3d6897..8d5c29a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -336,6 +336,13 @@ const api = { deleteInstance: (id: string): Promise => ipcRenderer.invoke("agent:deleteInstance", id), launchInstanceLogin: (id: string): Promise<{ command: string; opened: boolean }> => ipcRenderer.invoke("agent:launchInstanceLogin", id), + onInstancesChanged: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("agent:instancesChanged", listener); + return () => { + ipcRenderer.removeListener("agent:instancesChanged", listener); + }; + }, setConfigOption: (configId: string, value: string | boolean): Promise => ipcRenderer.invoke("agent:setConfigOption", configId, value), respondToPermission: (response: { diff --git a/src/electron.d.ts b/src/electron.d.ts index 0ff7dde..440439a 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -224,6 +224,7 @@ declare global { ) => Promise; deleteInstance: (id: string) => Promise; launchInstanceLogin: (id: string) => Promise<{ command: string; opened: boolean }>; + onInstancesChanged: (callback: () => void) => () => void; setConfigOption: ( configId: string, value: string | boolean, diff --git a/src/store/agent-instances-store.ts b/src/store/agent-instances-store.ts index 0480947..089b634 100644 --- a/src/store/agent-instances-store.ts +++ b/src/store/agent-instances-store.ts @@ -46,20 +46,44 @@ export const useAgentInstancesStore = create((set, get) => }, create: async (input) => { - const created = await window.omni.agent.createInstance(input); - await get().load(); - return created; + try { + const created = await window.omni.agent.createInstance(input); + await get().load(); + return created; + } catch (err) { + set({ error: err instanceof Error ? err.message : "Failed to add account" }); + throw err; + } }, update: async (id, input) => { - await window.omni.agent.updateInstance(id, input); - await get().load(); + try { + await window.omni.agent.updateInstance(id, input); + await get().load(); + } catch (err) { + set({ error: err instanceof Error ? err.message : "Failed to update account" }); + throw err; + } }, remove: async (id) => { - await window.omni.agent.deleteInstance(id); - await get().load(); + try { + await window.omni.agent.deleteInstance(id); + await get().load(); + } catch (err) { + set({ error: err instanceof Error ? err.message : "Failed to remove account" }); + throw err; + } }, launchLogin: async (id) => window.omni.agent.launchInstanceLogin(id), })); + +// Accounts are managed from the Settings window but consumed by every window +// (the main window's composer picker). Reload on a cross-window change so a +// newly added or removed account shows up without a restart. +if (typeof window !== "undefined" && window.omni?.agent?.onInstancesChanged) { + window.omni.agent.onInstancesChanged(() => { + void useAgentInstancesStore.getState().load(); + }); +} diff --git a/src/store/agent-registry-store.ts b/src/store/agent-registry-store.ts index 36c5a23..1edbfeb 100644 --- a/src/store/agent-registry-store.ts +++ b/src/store/agent-registry-store.ts @@ -132,3 +132,11 @@ export const useAgentRegistryStore = create((set, get) => ({ set({ probeResults: {}, skippedAgentIds: [], setupSkipped: false }); }, })); + +// The main window's agent picker must reflect accounts added or removed in the +// Settings window. Reload the registry whenever instances change. +if (typeof window !== "undefined" && window.omni?.agent?.onInstancesChanged) { + window.omni.agent.onInstancesChanged(() => { + void useAgentRegistryStore.getState().load(); + }); +} From 6be6175885e917ed670fc9b9fac6111789cad8b1 Mon Sep 17 00:00:00 2001 From: Harshith Pasupuleti Date: Sat, 26 Sep 2026 20:09:44 +0530 Subject: [PATCH 3/8] Create account credential root before login or spawn Codex (and similar CLIs) refuse to start when CODEX_HOME points at a path that does not exist. Materialize the profile directory when an account is created or updated, and self-heal it before login and spawn so accounts created earlier still work. --- electron/agent-instances.test.ts | 5 ++++- electron/agent-instances.ts | 26 +++++++++++++++++++++++++- electron/main.ts | 4 ++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/electron/agent-instances.test.ts b/electron/agent-instances.test.ts index f54ff4f..19cade1 100644 --- a/electron/agent-instances.test.ts +++ b/electron/agent-instances.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AcpAgentDescriptor } from "../contracts/acp.ts"; @@ -105,6 +105,9 @@ describe("agent instances", () => { const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); const profileVar = (created.env ?? []).find((entry) => entry.name === "CODEX_HOME"); expect(profileVar?.value).toContain("accounts"); + // The CLI refuses to start if the credential root doesn't exist, so the + // directory must be materialized at creation time. + expect(existsSync(profileVar?.value ?? "")).toBe(true); // The default instance keeps the ambient login (no forced profile dir). const defaultInstance = mod.getAgentInstance("codex-acp"); expect(defaultInstance?.env).toBeUndefined(); diff --git a/electron/agent-instances.ts b/electron/agent-instances.ts index 0ea0a28..b762a6c 100644 --- a/electron/agent-instances.ts +++ b/electron/agent-instances.ts @@ -1,3 +1,4 @@ +import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { app, safeStorage } from "electron"; import type { @@ -225,6 +226,23 @@ export function profileDirForInstance(driverId: string, instanceId: string): str return join(app.getPath("userData"), "accounts", driverId, slug(instanceId)); } +/** + * Some CLIs (notably Codex) refuse to start when their credential-root env var + * points at a path that doesn't exist yet. Create it eagerly on account + * creation and again before spawn/login so older accounts self-heal. + */ +export function ensureInstanceProfileDirs(instance: AcpAgentInstance): void { + const profileVar = PROFILE_ENV_BY_DRIVER[instance.driverId]; + if (!profileVar) return; + const entry = (instance.env ?? []).find((item) => item.name === profileVar); + if (!entry?.value) return; + try { + mkdirSync(entry.value, { recursive: true }); + } catch { + // Surfaced by the CLI at login/spawn time with a clearer message. + } +} + /** * Default env for a fresh non-default account: point the driver's credential * root at a Pipper-owned directory. Returns `[]` for key/token-based providers. @@ -278,7 +296,9 @@ export function createAgentInstance(input: AcpAgentInstanceInput): AcpAgentInsta row.created_at, row.updated_at, ); - return rowToInstance(row); + const instance = rowToInstance(row); + ensureInstanceProfileDirs(instance); + return instance; } export function updateAgentInstance( @@ -317,6 +337,7 @@ export function updateAgentInstance( updated.updatedAt ?? Date.now(), id, ); + ensureInstanceProfileDirs(updated); return updated; } @@ -386,6 +407,9 @@ export function resolveAgentInstanceDescriptor(instanceId: string): AcpAgentDesc if (!instance || !instance.enabled) return null; const driver = driverDescriptorById().get(instance.driverId); if (!driver) return null; + // Self-heal the credential root for accounts created before dirs were + // materialized, so the CLI doesn't refuse to start. + ensureInstanceProfileDirs(instance); return materialize(instance, driver); } diff --git a/electron/main.ts b/electron/main.ts index 2754732..b8f1f45 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -68,6 +68,7 @@ import { listAgentAccountSchemas, getAgentInstance, redactInstance, + ensureInstanceProfileDirs, buildInstanceLoginCommand, createAgentInstance, updateAgentInstance, @@ -470,6 +471,9 @@ async function launchInstanceLogin( ): Promise<{ command: string; opened: boolean }> { const instance = getAgentInstance(instanceId); if (!instance) throw new Error(`Unknown account: ${instanceId}`); + // The CLI (e.g. Codex) errors if its credential root doesn't exist, so make + // sure the profile directory is present before launching sign-in. + ensureInstanceProfileDirs(instance); const command = buildInstanceLoginCommand(instance); if (!command) { throw new Error("This provider signs in with an API key, not a browser login."); From 5de3cd87a3fee12c62c0ddc633024bbe96b7011c Mon Sep 17 00:00:00 2001 From: Harshith Pasupuleti Date: Sat, 26 Sep 2026 20:27:29 +0530 Subject: [PATCH 4/8] Show per-account sign-in status in Settings Probe each account via a throwaway ACP session and show a status pill (Signed in / Sign-in required / Not installed / Check failed), with a manual refresh. After launching login, poll until the account reports ready so completion is visible in the app, not just the terminal. --- src/components/agent-accounts-settings.tsx | 179 +++++++++++++++++++-- 1 file changed, 170 insertions(+), 9 deletions(-) diff --git a/src/components/agent-accounts-settings.tsx b/src/components/agent-accounts-settings.tsx index c273eec..2df6dd3 100644 --- a/src/components/agent-accounts-settings.tsx +++ b/src/components/agent-accounts-settings.tsx @@ -1,7 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; -import { LogIn, Plus, Trash2, Users } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LogIn, Plus, RefreshCw, Trash2, Users } from "lucide-react"; import { useAgentInstancesStore } from "@/store/agent-instances-store"; -import type { AcpAgentInstance, AgentAccountSchema } from "../../contracts/acp.ts"; +import type { + AcpAgentInstance, + AgentAccountSchema, + AgentProbeResult, +} from "../../contracts/acp.ts"; function envHint(instance: AcpAgentInstance, schema: AgentAccountSchema): string | null { const names = (instance.env ?? []).map((entry) => entry.name); @@ -11,34 +15,104 @@ function envHint(instance: AcpAgentInstance, schema: AgentAccountSchema): string return null; } +interface StatusView { + label: string; + textClass: string; + dotClass: string; +} + +function statusView(result: AgentProbeResult | undefined, probing: boolean): StatusView { + if (probing) { + return { + label: "Checking…", + textClass: "text-muted-foreground", + dotClass: "bg-muted-foreground/60", + }; + } + if (!result) { + return { + label: "Not checked", + textClass: "text-muted-foreground", + dotClass: "bg-muted-foreground/40", + }; + } + switch (result.status) { + case "ready": + return { label: "Signed in", textClass: "text-emerald-600", dotClass: "bg-emerald-500" }; + case "needs-auth": + return { label: "Sign-in required", textClass: "text-amber-600", dotClass: "bg-amber-500" }; + case "needs-install": + return { label: "Not installed", textClass: "text-amber-600", dotClass: "bg-amber-500" }; + case "error": + return { label: "Check failed", textClass: "text-destructive", dotClass: "bg-red-500" }; + default: + return { + label: "Unknown", + textClass: "text-muted-foreground", + dotClass: "bg-muted-foreground/60", + }; + } +} + +function StatusPill({ result, probing }: { result?: AgentProbeResult; probing: boolean }) { + const view = statusView(result, probing); + return ( + + + {view.label} + + ); +} + function AccountRow({ instance, schema, + result, + probing, onRemove, onSignIn, + onCheck, }: { instance: AcpAgentInstance; schema: AgentAccountSchema; + result?: AgentProbeResult; + probing: boolean; onRemove: (id: string) => void; onSignIn: (id: string) => void; + onCheck: (id: string) => void; }) { const isDefault = instance.id === instance.driverId; const hint = envHint(instance, schema); return (
-
- {instance.displayName} +
+ + {instance.displayName} + {isDefault ? ( - + DEFAULT ) : null} +
{hint ? (
{hint}
) : null}
+ {schema.supportsLogin ? (
- {schema.supportsLogin ? ( +
+ {instance.displayName} + +
- ) : null} - {isDefault ? null : ( - - )} + {schema.supportsLogin ? ( + + ) : null} + {isDefault ? ( +
+ ) : ( + + )} +
); } @@ -156,7 +131,7 @@ function AddAccountForm({ const canSubmit = name.trim().length > 0 && (!schema.authEnvVar || secret.trim().length > 0); return ( -
+
{ const accounts = instances.filter((instance) => instance.driverId === schema.driverId); + const primary = + accounts.find((instance) => instance.id === instance.driverId) ?? accounts[0]; return ( -
- {index > 0 ?
: null} -
-
- -
-
-
{schema.displayName}
-
- {accounts.length > 1 - ? `${accounts.length} accounts connected` - : "Add a second account for this provider"} -
-
+
+ {index > 0 ?
: null} +
+ + + {schema.displayName} + + {primary ? ( + + ) : null}
-
+
{accounts.map((instance) => ( = [ { id: "appearance", label: "Appearance" }, { id: "agents", label: "Agents" }, - { id: "accounts", label: "Accounts" }, + { id: "accounts", label: "Account" }, { id: "keyboard", label: "Keyboard" }, { id: "power", label: "Power" }, { id: "remote", label: "Remote" }, @@ -336,9 +336,8 @@ const SECTION_META: Record = { blurb: "Choose which coding agents Pipper can use.", }, accounts: { - title: "Accounts", - blurb: - "Each account runs in its own isolated configuration directory, so you can use a work and personal subscription side by side.", + title: "Account", + blurb: "", }, keyboard: { title: "Keyboard", @@ -372,7 +371,7 @@ function AppearanceView() { function AccountsView() { return ( - + ); @@ -497,7 +496,9 @@ export function SettingsApp() {

{meta.title}

-

{meta.blurb}

+ {meta.blurb ? ( +

{meta.blurb}

+ ) : null}
{section === "appearance" && }