diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 5c55688db..2957452c7 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -4,7 +4,8 @@ Paseo Agent is a built-in provider that runs Pi's coding-agent harness **in proc The provider id is **`paseo`** (the display name is "Paseo Agent"). Use it like any other provider, e.g. `paseo run --provider paseo --model / ...`. -This is a prototype. There is no UI yet — config-file only. +This is a prototype. There is no app UI yet. OpenRouter and ChatGPT setup have CLI +paths; other provider setup is still config-file based. > Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing > provider/config code, run `npm run build:server` (or run a source/dev daemon) before @@ -184,3 +185,30 @@ normal path is `paseo login chatgpt`. Paseo still never reads another tool's aut Other OAuth providers (Anthropic Pro/Max, Copilot) remain unwired; for those you can pass a pre-obtained bearer token via `apiKey`/env where accepted (e.g. `ANTHROPIC_OAUTH_TOKEN`). + +## CLI setup + +Configure an OpenRouter provider through the selected daemon: + +```bash +export OPENROUTER_API_KEY=... +paseo provider add openrouter openrouter-main \ + --model anthropic/claude-3.7-sonnet \ + --host localhost:7777 +``` + +For shell-history-safe key entry, pipe the key instead: + +```bash +printf '%s\n' "$OPENROUTER_API_KEY" | + paseo provider add openrouter openrouter-main \ + --api-key-stdin \ + --model anthropic/claude-3.7-sonnet \ + --host localhost:7777 +``` + +`paseo login chatgpt --host ` runs browser OAuth on the CLI machine, then sends +the returned credential to the selected daemon. The credential is stored in that daemon's +`$PASEO_HOME/paseo-agent/auth.json`; token values are not printed. `--device-code` is +currently local-only and is rejected when combined with `--host` until a daemon-run +device-code RPC exists. diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts new file mode 100644 index 000000000..3a2aea032 --- /dev/null +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -0,0 +1,109 @@ +import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { gotoAppShell, openSettings } from "./app"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { getServerId } from "./server-id"; +import { openSettingsHost } from "./settings"; + +type PaseoAgentDaemonClient = Pick< + InternalDaemonClient, + | "close" + | "connect" + | "removePaseoAgentProvider" + | "setPaseoAgentProvider" + | "storePaseoAgentChatGptCredential" +>; + +interface OpenRouterProviderInput { + name: string; + apiKey: string; + models: string[]; +} + +interface ExpectedProvider { + name: string; + providerType: "openai-codex" | "openrouter"; + auth: "API key configured" | "ChatGPT login stored"; + modelCount: number; +} + +async function connectPaseoAgentClient(): Promise { + return connectDaemonClient({ clientIdPrefix: "paseo-agent-e2e" }); +} + +export async function openPaseoAgentSettings(page: Page): Promise { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click(); + const sheet = page.getByTestId("paseo-agent-settings-sheet"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible(); +} + +export async function addOpenRouterProvider( + page: Page, + provider: OpenRouterProviderInput, +): Promise { + await page.getByRole("button", { name: "Add OpenRouter", exact: true }).click(); + await expect(page.getByText("Add OpenRouter provider", { exact: true })).toBeVisible(); + + await page.getByLabel("Provider name").fill(provider.name); + await page.getByLabel("OpenRouter API key").fill(provider.apiKey); + await page.getByLabel("OpenRouter models").fill(provider.models.join("\n")); + await page.getByRole("button", { name: "Save provider", exact: true }).click(); + + await expect(page.getByText("Add OpenRouter provider", { exact: true })).toHaveCount(0); + await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0); +} + +export async function expectInferenceProviderListed( + page: Page, + expected: ExpectedProvider, +): Promise { + const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`; + await expect( + page.getByRole("listitem", { + name: new RegExp( + `${expected.name}.*${expected.providerType}.*${modelLabel}.*${expected.auth}`, + ), + }), + ).toBeVisible(); +} + +export async function seedChatGptProvider(providerName: string): Promise { + const client = await connectPaseoAgentClient(); + try { + await client.setPaseoAgentProvider({ + name: providerName, + providerType: "openai-codex", + options: { + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + }); + await client.storePaseoAgentChatGptCredential({ + providerName, + credential: { + type: "oauth", + access: "fake-access-token", + refresh: "fake-refresh-token", + expires: 4_102_444_800, + futureField: { passthrough: true }, + }, + }); + } finally { + await client.close().catch(() => undefined); + } +} + +export async function cleanupPaseoAgentProviders(providerNames: Iterable): Promise { + const client = await connectPaseoAgentClient(); + try { + for (const name of providerNames) { + await client.removePaseoAgentProvider(name); + } + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/app/e2e/paseo-agent-provider-config.spec.ts b/packages/app/e2e/paseo-agent-provider-config.spec.ts new file mode 100644 index 000000000..784857eef --- /dev/null +++ b/packages/app/e2e/paseo-agent-provider-config.spec.ts @@ -0,0 +1,52 @@ +import { test } from "./fixtures"; +import { + addOpenRouterProvider, + cleanupPaseoAgentProviders, + expectInferenceProviderListed, + openPaseoAgentSettings, + seedChatGptProvider, +} from "./helpers/paseo-agent"; + +const OPENROUTER_PROVIDER = "phase-e-openrouter-ui"; +const CHATGPT_PROVIDER = "phase-e-chatgpt-ui"; + +test.describe("Paseo Agent provider configuration", () => { + const providerNamesToCleanup = new Set(); + + test.afterEach(async () => { + await cleanupPaseoAgentProviders(providerNamesToCleanup); + providerNamesToCleanup.clear(); + }); + + test("adds an OpenRouter inference provider from Settings", async ({ page }) => { + providerNamesToCleanup.add(OPENROUTER_PROVIDER); + + await openPaseoAgentSettings(page); + await addOpenRouterProvider(page, { + name: OPENROUTER_PROVIDER, + apiKey: "sk-or-phase-e-write-only", + models: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"], + }); + + await expectInferenceProviderListed(page, { + name: OPENROUTER_PROVIDER, + providerType: "openrouter", + modelCount: 2, + auth: "API key configured", + }); + }); + + test("shows a stored ChatGPT login as a read-only inference provider row", async ({ page }) => { + providerNamesToCleanup.add(CHATGPT_PROVIDER); + + await seedChatGptProvider(CHATGPT_PROVIDER); + await openPaseoAgentSettings(page); + + await expectInferenceProviderListed(page, { + name: CHATGPT_PROVIDER, + providerType: "openai-codex", + modelCount: 1, + auth: "ChatGPT login stored", + }); + }); +}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx new file mode 100644 index 000000000..32093efd3 --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx @@ -0,0 +1,258 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; + +const { theme, hookState, setProviderMock } = vi.hoisted(() => ({ + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500" }, + borderRadius: { lg: 8 }, + colors: { + surface1: "#111", + surface2: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + destructive: "#f00", + statusSuccess: "#0f0", + }, + }, + hookState: { + supported: true, + providers: [] as RedactedPaseoAgentProviderConfig[], + isLoading: false, + error: null as string | null, + }, + setProviderMock: vi.fn(async () => null), +})); + +vi.mock("react-native", () => ({ + View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("div", { "data-testid": testID }, children), + Text: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("span", { "data-testid": testID }, children), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, +})); + +vi.mock("lucide-react-native", () => ({ + Plus: () => React.createElement("span", { "data-icon": "Plus" }), +})); + +vi.mock("@/constants/platform", () => ({ isWeb: true })); + +vi.mock("@/components/adaptive-modal-sheet", () => ({ + AdaptiveModalSheet: ({ + children, + footer, + visible, + testID, + }: { + children?: React.ReactNode; + footer?: React.ReactNode; + visible?: boolean; + testID?: string; + }) => (visible ? React.createElement("div", { "data-testid": testID }, children, footer) : null), + AdaptiveTextInput: ({ + onChangeText, + accessibilityLabel, + testID, + }: { + onChangeText?: (value: string) => void; + accessibilityLabel?: string; + testID?: string; + }) => + React.createElement("input", { + "data-testid": testID, + "aria-label": accessibilityLabel, + onChange: (event: React.ChangeEvent) => onChangeText?.(event.target.value), + }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + React.createElement( + "button", + { + type: "button", + "data-testid": testID, + disabled, + onClick: disabled ? undefined : onPress, + }, + children, + ), +})); + +vi.mock("@/hooks/use-paseo-agent-providers", () => ({ + usePaseoAgentProviders: () => ({ + supported: hookState.supported, + providers: hookState.providers, + defaultModel: null, + isLoading: hookState.isLoading, + error: hookState.error, + refresh: vi.fn(async () => {}), + setProvider: setProviderMock, + }), +})); + +import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet"; + +function openRouterProvider(): RedactedPaseoAgentProviderConfig { + return { + name: "openrouter-main", + providerType: "openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }; +} + +describe("PaseoAgentSettingsSheet", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hookState.supported = true; + hookState.providers = []; + hookState.isLoading = false; + hookState.error = null; + setProviderMock.mockReset(); + setProviderMock.mockResolvedValue(null); + }); + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + function render(): void { + act(() => { + root?.render(); + }); + } + + function type(testID: string, value: string): void { + const input = container?.querySelector(`[data-testid="${testID}"]`); + if (!input) throw new Error(`No input ${testID}`); + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + act(() => { + setValue?.call(input, value); + input.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + } + + function click(testID: string): void { + const el = container?.querySelector(`[data-testid="${testID}"]`); + if (!el) throw new Error(`No element ${testID}`); + act(() => { + el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); + } + + it("shows the update-host message and hides the add button when unsupported", () => { + hookState.supported = false; + render(); + + expect( + container?.querySelector('[data-testid="paseo-agent-unsupported"]')?.textContent, + ).toContain("Update the host to configure Paseo Agent."); + expect(container?.querySelector('[data-testid="paseo-agent-add-openrouter"]')).toBeNull(); + }); + + it("shows the error message instead of the empty state when the fetch fails", () => { + hookState.error = "Host is not connected"; + render(); + + const text = container?.textContent ?? ""; + expect(text).toContain("Host is not connected"); + expect(text).not.toContain("No inference providers configured yet."); + }); + + it("lists configured providers with type, model count, and auth state", () => { + hookState.providers = [openRouterProvider()]; + render(); + + const text = container?.textContent ?? ""; + expect(text).toContain("openrouter-main"); + expect(text).toContain("openrouter"); + expect(text).toContain("1 model"); + expect(text).toContain("API key configured"); + }); + + it("submits OpenRouter setup with name, api key, and parsed models", async () => { + render(); + + click("paseo-agent-add-openrouter"); + type("paseo-openrouter-name", "my-router"); + type("paseo-openrouter-api-key", "sk-or-secret"); + type("paseo-openrouter-models", "anthropic/claude-3.7-sonnet, openai/gpt-4o"); + + await act(async () => { + click("paseo-openrouter-submit"); + }); + + expect(setProviderMock).toHaveBeenCalledTimes(1); + expect(setProviderMock).toHaveBeenCalledWith({ + name: "my-router", + providerType: "openrouter", + options: { + apiKey: "sk-or-secret", + models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }], + }, + }); + }); + + it("omits api key from the payload when left blank", async () => { + render(); + + click("paseo-agent-add-openrouter"); + type("paseo-openrouter-models", "anthropic/claude-3.7-sonnet"); + + await act(async () => { + click("paseo-openrouter-submit"); + }); + + expect(setProviderMock).toHaveBeenCalledWith({ + name: "openrouter", + providerType: "openrouter", + options: { + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + }); +}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx new file mode 100644 index 000000000..9f3009bad --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -0,0 +1,413 @@ +import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Plus } from "lucide-react-native"; +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { isWeb } from "@/constants/platform"; +import { usePaseoAgentProviders } from "@/hooks/use-paseo-agent-providers"; + +interface PaseoAgentSettingsSheetProps { + serverId: string; + visible: boolean; + onClose: () => void; +} + +const MAIN_SNAP_POINTS = ["65%", "92%"]; +const ADD_SNAP_POINTS = ["70%", "92%"]; +const HEADER: SheetHeader = { title: "Paseo Agent" }; +const ADD_HEADER: SheetHeader = { title: "Add OpenRouter provider" }; +const DEFAULT_PROVIDER_NAME = "openrouter"; + +function authLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string { + if (auth.kind === "oauth") { + return auth.configured ? "ChatGPT login stored" : "Login required"; + } + if (auth.kind === "none") { + return "No auth"; + } + return auth.configured ? "API key configured" : "API key required"; +} + +function parseModelIds(raw: string): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const part of raw.split(/[\n,]/)) { + const id = part.trim(); + if (id.length > 0 && !seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + return ids; +} + +function ProviderRow({ provider }: { provider: RedactedPaseoAgentProviderConfig }) { + const modelCount = provider.models.length; + const modelLabel = modelCount === 1 ? "1 model" : `${modelCount} models`; + const auth = authLabel(provider.auth); + return ( + + + + + {provider.name} + + + {provider.providerType} · {modelLabel} · {auth} + + + + ); +} + +function AddOpenRouterSubSheet({ + serverId, + visible, + onClose, +}: { + serverId: string; + visible: boolean; + onClose: () => void; +}) { + const { setProvider } = usePaseoAgentProviders(serverId); + const [name, setName] = useState(DEFAULT_PROVIDER_NAME); + const [apiKey, setApiKey] = useState(""); + const [models, setModels] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [resetKey, bumpResetKey] = useReducer((key: number) => key + 1, 0); + + useEffect(() => { + if (!visible) { + setName(DEFAULT_PROVIDER_NAME); + setApiKey(""); + setModels(""); + setError(null); + setSaving(false); + bumpResetKey(); + } + }, [visible]); + + const trimmedName = name.trim(); + const modelIds = useMemo(() => parseModelIds(models), [models]); + const canSubmit = trimmedName.length > 0 && modelIds.length > 0 && !saving; + + const handleSubmit = useCallback(() => { + if (!canSubmit) return; + setError(null); + setSaving(true); + const trimmedKey = apiKey.trim(); + void setProvider({ + name: trimmedName, + providerType: "openrouter", + options: { + models: modelIds.map((id) => ({ id })), + ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : {}), + }, + }) + .then(() => { + setApiKey(""); + onClose(); + return undefined; + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to save provider"); + }) + .finally(() => setSaving(false)); + }, [apiKey, canSubmit, modelIds, onClose, setProvider, trimmedName]); + + return ( + + + Provider name + + + API key + + + Stored on the host and never shown again. Leave blank to use OPENROUTER_API_KEY on the + host. + + + Models + + One model id per line, or comma-separated. + + {error ? ( + + {error} + + ) : null} + + + + + + + + ); +} + +export function PaseoAgentSettingsSheet({ + serverId, + visible, + onClose, +}: PaseoAgentSettingsSheetProps) { + const { supported, providers, isLoading, error } = usePaseoAgentProviders(serverId); + const [addOpen, setAddOpen] = useState(false); + + useEffect(() => { + if (!visible) { + setAddOpen(false); + } + }, [visible]); + + const handleOpenAdd = useCallback(() => setAddOpen(true), []); + const handleCloseAdd = useCallback(() => setAddOpen(false), []); + + const footer = useMemo(() => { + if (!supported) { + return undefined; + } + return ( + + + + ); + }, [supported, handleOpenAdd]); + + let body: React.ReactNode; + if (!supported) { + body = ( + + Update the host to configure Paseo Agent. + + ); + } else if (error) { + body = ( + + {error} + + ); + } else if (isLoading) { + body = ( + + Loading… + + ); + } else if (providers.length === 0) { + body = ( + + No inference providers configured yet. + + ); + } else { + body = ( + + {providers.map((provider) => ( + + ))} + + ); + } + + return ( + <> + + {body} + + {supported ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + overflow: "hidden", + }, + providerRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + providerText: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + providerName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + providerMeta: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + dotAvailable: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.colors.statusSuccess, + }, + dotMuted: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.colors.foregroundMuted, + }, + stateBox: { + minHeight: 96, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + stateText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + footerActions: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + }, + formGroup: { + gap: theme.spacing[2], + }, + formLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + marginTop: theme.spacing[2], + }, + formHint: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + formInput: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.sm, + }, + modelsInput: { + minHeight: 80, + textAlignVertical: "top", + }, + errorText: { + fontSize: theme.fontSize.xs, + color: theme.colors.destructive, + }, + formActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + marginTop: theme.spacing[3], + }, +})); + +const FORM_INPUT_STYLE = [styles.formInput, isWeb && { outlineStyle: "none" }]; +const MODELS_INPUT_STYLE = [ + styles.formInput, + styles.modelsInput, + isWeb && { outlineStyle: "none" }, +]; diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx index 7518fc28f..80df3cc93 100644 --- a/packages/app/src/components/provider-settings-host.tsx +++ b/packages/app/src/components/provider-settings-host.tsx @@ -1,7 +1,10 @@ import { useCallback } from "react"; +import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; +const PASEO_AGENT_PROVIDER = "paseo"; + export function ProviderSettingsHost() { const serverId = useProviderSettingsStore((state) => state.serverId); const provider = useProviderSettingsStore((state) => state.provider); @@ -16,6 +19,10 @@ export function ProviderSettingsHost() { return null; } + if (provider === PASEO_AGENT_PROVIDER) { + return ; + } + return ( ; + +interface UsePaseoAgentProvidersResult { + supported: boolean; + providers: RedactedPaseoAgentProviderConfig[]; + defaultModel: string | null; + isLoading: boolean; + error: string | null; + refresh: () => Promise; + setProvider: ( + input: PaseoAgentSetProviderInput, + ) => Promise; +} + +export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult { + const queryClient = useQueryClient(); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + // COMPAT(paseoAgentConfig): added in v0.1.X, drop the gate when floor >= v0.1.X. + const supported = useSessionStore( + (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true, + ); + const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]); + + const query = useQuery({ + queryKey, + enabled: Boolean(supported && serverId && client && isConnected), + staleTime: 30_000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.getPaseoAgentProviders(); + }, + }); + + const refresh = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); + + const error = query.data?.error ?? describeQueryError(query.error); + + const setProviderMutation = useMutation({ + mutationFn: async (input: PaseoAgentSetProviderInput) => { + if (!client) { + throw new Error("Host is not connected"); + } + const result = await client.setPaseoAgentProvider(input); + if (!result.success) { + throw new Error(result.error ?? "Failed to save provider"); + } + return result.provider; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + const { mutateAsync: setProviderAsync } = setProviderMutation; + + const setProvider = useCallback( + (input: PaseoAgentSetProviderInput) => setProviderAsync(input), + [setProviderAsync], + ); + + return { + supported, + providers: query.data?.providers ?? [], + defaultModel: query.data?.defaultModel ?? null, + isLoading: query.isLoading, + error, + refresh, + setProvider, + }; +} diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts index cc01063a1..9bb6807c2 100644 --- a/packages/cli/src/commands/login/index.test.ts +++ b/packages/cli/src/commands/login/index.test.ts @@ -4,9 +4,9 @@ import { createCli } from "../../cli.js"; import { createLoginCommand } from "./index.js"; interface RecordedLogin { - providerInstance: string; - envHome: string | undefined; mode: "browser" | "device"; + providerInstance?: string; + envHome?: string | undefined; } describe("paseo login command", () => { @@ -24,13 +24,15 @@ describe("paseo login command", () => { const flags = chatgpt?.options.map((option) => option.long); // Default flow is browser; device-code is an opt-in fallback. expect(flags).toContain("--device-code"); + expect(flags).toContain("--host"); expect(flags).toContain("--home"); // It must not require a copy/paste device flow by default. expect(chatgpt?.description().toLowerCase()).toContain("chatgpt"); }); - it("runs browser login by default and opens the Pi auth URL", async () => { + it("runs browser login by default and stores the credential through the daemon", async () => { const recorded: RecordedLogin[] = []; + const stored: unknown[] = []; const openedUrls: string[] = []; const output: string[] = []; @@ -44,29 +46,57 @@ describe("paseo login command", () => { promptForCode: async () => { throw new Error("manual code prompt should not be used for successful browser login"); }, - loginBrowser: async (options) => { - recorded.push({ - providerInstance: options.providerInstance, - envHome: options.env?.PASEO_HOME, - mode: "browser", - }); + loginBrowserCredential: async (options) => { + recorded.push({ mode: "browser" }); options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo"); options.onProgress?.("callback complete"); - return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; + return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 }; + }, + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return { + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentConfig: true }, + }), + storePaseoAgentChatGptCredential: async (input) => { + stored.push(input); + return { + requestId: "request-1", + success: true, + providerName: input.providerName, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }; + }, + close: async () => {}, + }; }, loginDeviceCode: async () => { throw new Error("device-code login should not be used by default"); }, }); - await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]); + await login.parseAsync(["node", "login", "chatgpt", "--host", "localhost:7777"]); - expect(recorded).toEqual([ - { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" }, + expect(recorded).toEqual([{ mode: "browser" }]); + expect(stored).toEqual([ + { + providerName: "chatgpt", + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }, + }, ]); expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]); expect(output.join("\n")).toContain("browser flow"); - expect(output.join("\n")).toContain("/tmp/paseo-home/paseo-agent/auth.json"); + expect(output.join("\n")).toContain("selected daemon (localhost:7777)"); + expect(output.join("\n")).not.toContain("access-token"); + expect(output.join("\n")).not.toContain("refresh-token"); }); it("uses device-code login only when explicitly requested", async () => { @@ -82,9 +112,12 @@ describe("paseo login command", () => { promptForCode: async () => { throw new Error("manual browser prompt should not run for --device-code"); }, - loginBrowser: async () => { + loginBrowserCredential: async () => { throw new Error("browser login should not run for --device-code"); }, + connectDaemon: async () => { + throw new Error("daemon client should not be used for local --device-code"); + }, loginDeviceCode: async (options) => { recorded.push({ providerInstance: options.providerInstance, @@ -116,4 +149,116 @@ describe("paseo login command", () => { expect(output.join("\n")).toContain("headless device-code flow"); expect(output.join("\n")).toContain("ABCD-EFGH"); }); + + it("rejects --device-code with --host instead of writing local auth for a remote host", async () => { + const output: string[] = []; + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => { + throw new Error("browser opener should not run"); + }, + promptForCode: async () => { + throw new Error("prompt should not run"); + }, + loginBrowserCredential: async () => { + throw new Error("browser login should not run"); + }, + loginDeviceCode: async () => { + throw new Error("device-code login should not run with --host"); + }, + connectDaemon: async () => { + throw new Error("daemon client should not be used"); + }, + }); + + await login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]); + + expect(output.join("\n")).toContain("--device-code cannot be combined with --host"); + }); + + it("asks for a host update instead of sending credentials to an old daemon", async () => { + const stored: unknown[] = []; + const output: string[] = []; + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => { + throw new Error("browser opener should not run without the capability flag"); + }, + promptForCode: async () => { + throw new Error("manual code prompt should not be used"); + }, + loginBrowserCredential: async () => { + throw new Error("browser login should not run without the capability flag"); + }, + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: {}, + }), + storePaseoAgentChatGptCredential: async (input) => { + stored.push(input); + throw new Error("store RPC should not be called without the capability flag"); + }, + close: async () => {}, + }), + loginDeviceCode: async () => { + throw new Error("device-code login should not run"); + }, + }); + + await login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]); + + expect(stored).toEqual([]); + expect(output.join("\n")).toContain("Update the host to configure Paseo Agent providers."); + }); + + it("does not echo password-bearing host URIs after remote login", async () => { + const output: string[] = []; + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => true, + promptForCode: async () => { + throw new Error("manual code prompt should not be used"); + }, + loginBrowserCredential: async () => ({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }), + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentConfig: true }, + }), + storePaseoAgentChatGptCredential: async (input) => ({ + requestId: "request-1", + success: true, + providerName: input.providerName, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }), + close: async () => {}, + }), + loginDeviceCode: async () => { + throw new Error("device-code login should not run"); + }, + }); + + await login.parseAsync([ + "node", + "login", + "chatgpt", + "--host", + "tcp://remote:7777?ssl=true&password=super-secret", + ]); + + expect(output.join("\n")).toContain("tcp://remote:7777?ssl=true"); + expect(output.join("\n")).not.toContain("super-secret"); + }); }); diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 0a7fc6eef..7046d60ea 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -1,23 +1,28 @@ import { createInterface } from "node:readline/promises"; import { Command } from "commander"; import { + loginCodexBrowser, loginAndStoreCodex, - loginAndStoreCodexBrowser, type CodexDeviceCodeInfo, + type StoredCodexOAuthCredential, } from "@getpaseo/server"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { addDaemonHostOption } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; import { openBrowserUrl } from "../../utils/open-browser.js"; // First-class auth UX: `paseo login chatgpt`. // Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's -// helper; `--device-code` is a headless fallback. Credentials are stored in the -// Paseo-owned store ($PASEO_HOME/paseo-agent/auth.json). No foreign auth files are read. +// helper; credentials are then sent to the selected daemon for storage. `--device-code` +// remains a local-only fallback until a daemon-run device-code RPC exists. const PROVIDER_INSTANCE = "chatgpt"; interface LoginChatgptOptions { deviceCode?: boolean; home?: string; + host?: string; } interface LoginResult { @@ -26,7 +31,12 @@ interface LoginResult { interface LoginCommandDependencies { loginDeviceCode: typeof loginAndStoreCodex; - loginBrowser: typeof loginAndStoreCodexBrowser; + loginBrowserCredential: typeof loginCodexBrowser; + connectDaemon: (options: { + host?: string; + }) => Promise< + Pick + >; openBrowser: (url: string) => boolean; promptForCode: (message: string) => Promise; write: (message: string) => void; @@ -35,7 +45,8 @@ interface LoginCommandDependencies { const defaultDependencies: LoginCommandDependencies = { loginDeviceCode: loginAndStoreCodex, - loginBrowser: loginAndStoreCodexBrowser, + loginBrowserCredential: loginCodexBrowser, + connectDaemon: connectToDaemon, openBrowser: openBrowserUrl, promptForCode, write: (message) => console.log(message), @@ -46,6 +57,29 @@ function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { return home ? { ...process.env, PASEO_HOME: home } : process.env; } +function requirePaseoAgentConfigFeature(client: Pick) { + if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) { + return; + } + throw new Error("Update the host to configure Paseo Agent providers."); +} + +function formatDaemonTarget(host: string | undefined): string { + if (!host) { + return "local daemon"; + } + try { + if (host.startsWith("tcp://")) { + const url = new URL(host); + url.searchParams.delete("password"); + return `selected daemon (${url.toString()})`; + } + } catch { + // Invalid hosts fail during connection; this path only formats the success message. + } + return `selected daemon (${host})`; +} + async function promptForCode(message: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -69,6 +103,12 @@ async function runChatgptLogin( const env = resolveEnv(options.home); const { write } = dependencies; + if (options.deviceCode && options.host) { + throw new Error( + "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", + ); + } + if (options.deviceCode) { write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); const { path } = await dependencies.loginDeviceCode({ @@ -80,43 +120,59 @@ async function runChatgptLogin( return { path }; } - write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); - const { path } = await dependencies.loginBrowser({ - providerInstance: PROVIDER_INSTANCE, - env, - onAuthUrl: (url) => { - const opened = dependencies.openBrowser(url); - write( - opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:", - ); - write(` ${url}\n`); - write("Waiting for you to approve in the browser…"); - write("(If the browser didn't open, copy the URL above. You can also paste the code here.)"); - }, - onProgress: (message) => write(message), - promptForCode: dependencies.promptForCode, - }); - write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); - return { path }; + const client = await dependencies.connectDaemon({ host: options.host }); + try { + requirePaseoAgentConfigFeature(client); + write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); + const credential: StoredCodexOAuthCredential = await dependencies.loginBrowserCredential({ + onAuthUrl: (url) => { + const opened = dependencies.openBrowser(url); + write( + opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:", + ); + write(` ${url}\n`); + write("Waiting for you to approve in the browser…"); + write( + "(If the browser didn't open, copy the URL above. You can also paste the code here.)", + ); + }, + onProgress: (message) => write(message), + promptForCode: dependencies.promptForCode, + }); + const result = await client.storePaseoAgentChatGptCredential({ + providerName: PROVIDER_INSTANCE, + credential, + }); + if (!result.success || result.error) { + throw new Error(result.error ?? "Daemon rejected the ChatGPT credential"); + } + } finally { + await client.close().catch(() => {}); + } + + const target = formatDaemonTarget(options.host); + write(`\n✓ Logged in. Credential stored on ${target} in its Paseo-owned auth store.`); + return { path: target }; } export function createLoginCommand(dependencies: Partial = {}): Command { const deps = { ...defaultDependencies, ...dependencies }; const login = new Command("login").description("Authenticate Paseo providers"); - login - .command("chatgpt") - .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") - .option("--device-code", "Use the headless device-code flow instead of the browser flow") - .option("--home ", "Paseo home directory (default: ~/.paseo or $PASEO_HOME)") - .action(async (options: LoginChatgptOptions) => { - try { - await runChatgptLogin(options, deps); - } catch (error) { - deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } - }); + addDaemonHostOption( + login + .command("chatgpt") + .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") + .option("--device-code", "Use the headless device-code flow instead of the browser flow") + .option("--home ", "Paseo home directory for local --device-code only"), + ).action(async (options: LoginChatgptOptions) => { + try { + await runChatgptLogin(options, deps); + } catch (error) { + deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + }); return login; } diff --git a/packages/cli/src/commands/provider/index.ts b/packages/cli/src/commands/provider/index.ts index acd513fa0..3173dec0b 100644 --- a/packages/cli/src/commands/provider/index.ts +++ b/packages/cli/src/commands/provider/index.ts @@ -1,10 +1,13 @@ import { Command } from "commander"; import { runLsCommand } from "./ls.js"; import { runModelsCommand } from "./models.js"; +import { addOpenRouterOptions, runAddOpenRouterCommand } from "./openrouter.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; -export function createProviderCommand(): Command { +export function createProviderCommand( + dependencies: Parameters[3] = {}, +): Command { const provider = new Command("provider").description("Manage agent providers"); addJsonAndDaemonHostOptions( @@ -19,5 +22,12 @@ export function createProviderCommand(): Command { .option("--thinking", "Include thinking option IDs for each model"), ).action(withOutput(runModelsCommand)); + const add = provider.command("add").description("Configure a provider"); + addJsonAndDaemonHostOptions(addOpenRouterOptions(add.command("openrouter"))).action( + withOutput>["data"], [string]>( + (name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies), + ), + ); + return provider; } diff --git a/packages/cli/src/commands/provider/openrouter.test.ts b/packages/cli/src/commands/provider/openrouter.test.ts new file mode 100644 index 000000000..805c4eb47 --- /dev/null +++ b/packages/cli/src/commands/provider/openrouter.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { render } from "../../output/index.js"; +import { runAddOpenRouterCommand } from "./openrouter.js"; + +describe("provider add openrouter", () => { + it("sends OpenRouter config to the selected daemon and redacts output", async () => { + const calls: unknown[] = []; + const result = await runAddOpenRouterCommand( + "openrouter-main", + { + host: "localhost:7777", + apiKeyStdin: true, + model: ["anthropic/claude-3.7-sonnet", "openai/gpt-4o"], + }, + {} as never, + { + readStdin: async () => "redaction-sentinel\n", + env: {}, + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return { + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentConfig: true }, + }), + setPaseoAgentProvider: async (input) => { + calls.push(input); + return { + requestId: "request-1", + success: true, + provider: { + name: input.name, + providerType: "openrouter", + models: input.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + close: async () => {}, + }; + }, + }, + ); + + expect(calls).toEqual([ + { + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "redaction-sentinel", + models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }], + }, + }, + ]); + + const json = render(result, { format: "json" }); + const table = render(result, { format: "table", noColor: true }); + expect(json).not.toContain("redaction-sentinel"); + expect(table).not.toContain("redaction-sentinel"); + expect(table).toContain("openrouter-main"); + expect(table).toContain("anthropic/claude-3.7-sonnet"); + }); + + it("uses OPENROUTER_API_KEY by default and requires explicit models", async () => { + await expect( + runAddOpenRouterCommand("openrouter-main", { model: [] }, {} as never, { + env: { OPENROUTER_API_KEY: "redaction-sentinel" }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async () => { + throw new Error("daemon should not be called without models"); + }, + }), + ).rejects.toMatchObject({ code: "MISSING_MODELS" }); + }); + + it("asks for a host update instead of sending provider config to an old daemon", async () => { + const calls: unknown[] = []; + + await expect( + runAddOpenRouterCommand( + "openrouter-main", + { + host: "localhost:7777", + apiKeyStdin: true, + model: ["anthropic/claude-3.7-sonnet"], + }, + {} as never, + { + readStdin: async () => "redaction-sentinel\n", + env: {}, + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: {}, + }), + setPaseoAgentProvider: async (input) => { + calls.push(input); + throw new Error("set provider RPC should not run without the capability flag"); + }, + close: async () => {}, + }), + }, + ), + ).rejects.toMatchObject({ + code: "HOST_UPDATE_REQUIRED", + message: "Update the host to configure Paseo Agent providers.", + }); + + expect(calls).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/provider/openrouter.ts b/packages/cli/src/commands/provider/openrouter.ts new file mode 100644 index 000000000..dca369de6 --- /dev/null +++ b/packages/cli/src/commands/provider/openrouter.ts @@ -0,0 +1,182 @@ +import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; + +import { connectToDaemon } from "../../utils/client.js"; +import { collectMultiple } from "../../utils/command-options.js"; +import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; + +interface OpenRouterAddOptions extends CommandOptions { + apiKey?: string; + apiKeyEnv?: string; + apiKeyStdin?: boolean; + model?: string[]; +} + +interface OpenRouterConfiguredItem { + name: string; + providerType: string; + auth: string; + available: string; + models: string; +} + +interface OpenRouterDependencies { + connectDaemon: (options: { + host?: string; + }) => Promise>; + env: NodeJS.ProcessEnv; + readStdin: () => Promise; +} + +const DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY"; + +const defaultDependencies: OpenRouterDependencies = { + connectDaemon: connectToDaemon, + env: process.env, + readStdin, +}; + +export const openRouterConfiguredSchema: OutputSchema = { + idField: "name", + columns: [ + { header: "NAME", field: "name", width: 20 }, + { header: "TYPE", field: "providerType", width: 12 }, + { header: "AUTH", field: "auth", width: 16 }, + { header: "AVAILABLE", field: "available", width: 10 }, + { header: "MODELS", field: "models", width: 50 }, + ], +}; + +async function readStdin(): Promise { + process.stdin.setEncoding("utf8"); + let value = ""; + for await (const chunk of process.stdin) { + value += chunk; + } + return value; +} + +function normalizeModels(rawModels: string[] | undefined): string[] { + return (rawModels ?? []) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); +} + +async function resolveApiKey( + options: OpenRouterAddOptions, + dependencies: OpenRouterDependencies, +): Promise { + if (options.apiKey) { + return options.apiKey; + } + + if (options.apiKeyStdin) { + const value = (await dependencies.readStdin()).trim(); + if (value) { + return value; + } + throw { + code: "MISSING_API_KEY", + message: "No OpenRouter API key was read from stdin", + }; + } + + const envName = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV; + const value = dependencies.env[envName]?.trim(); + if (value) { + return value; + } + + throw { + code: "MISSING_API_KEY", + message: `OpenRouter API key not found in $${envName}`, + details: + "Set OPENROUTER_API_KEY, pass --api-key-env , or pipe the key with --api-key-stdin.", + }; +} + +function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRouterConfiguredItem { + return { + name: provider.name, + providerType: provider.providerType, + auth: provider.auth.configured ? (provider.auth.source ?? "configured") : "not configured", + available: provider.available ? "yes" : "no", + models: provider.models.map((model) => model.id).join(", "), + }; +} + +function requirePaseoAgentConfigFeature(client: Pick) { + if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) { + return; + } + throw { + code: "HOST_UPDATE_REQUIRED", + message: "Update the host to configure Paseo Agent providers.", + }; +} + +export async function runAddOpenRouterCommand( + name: string, + options: OpenRouterAddOptions, + _command: Command, + dependencies: Partial = {}, +): Promise> { + const deps = { ...defaultDependencies, ...dependencies }; + const models = normalizeModels(options.model); + if (models.length === 0) { + throw { + code: "MISSING_MODELS", + message: "At least one OpenRouter model is required", + details: "Pass --model . Repeat --model to configure more than one.", + }; + } + + const apiKey = await resolveApiKey(options, deps); + const client = await deps.connectDaemon({ host: options.host }); + try { + requirePaseoAgentConfigFeature(client); + const result = await client.setPaseoAgentProvider({ + name, + providerType: "openrouter", + options: { + apiKey, + models: models.map((id) => ({ id })), + }, + }); + if (!result.success || !result.provider) { + throw { + code: "PROVIDER_CONFIG_FAILED", + message: result.error ?? "Daemon rejected the OpenRouter provider config", + }; + } + + return { + type: "single", + data: toConfiguredItem(result.provider), + schema: openRouterConfiguredSchema, + }; + } finally { + await client.close().catch(() => {}); + } +} + +export function addOpenRouterOptions(command: Command): Command { + return command + .description("Configure an OpenRouter inference provider for Paseo Agent") + .argument("", "Provider instance name") + .option( + "--model ", + "OpenRouter model ID to expose (repeatable, comma-separated also accepted)", + collectMultiple, + [], + ) + .option( + "--api-key-env ", + `Environment variable containing the API key`, + DEFAULT_API_KEY_ENV, + ) + .option("--api-key-stdin", "Read the API key from stdin") + .option("--api-key ", "OpenRouter API key (prefer env or stdin to avoid shell history)"); +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4218cbbce..8c6d31286 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -94,7 +94,16 @@ import type { AgentProvider, AgentSessionConfig, } from "@getpaseo/protocol/agent-types"; -import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; +import type { + MutableDaemonConfig, + MutableDaemonConfigPatch, + PaseoAgentGetProvidersResponse, + PaseoAgentOAuthCredential, + PaseoAgentRemoveProviderResponse, + PaseoAgentSetProviderRequest, + PaseoAgentSetProviderResponse, + PaseoAgentStoreChatGptCredentialResponse, +} from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { @@ -3861,6 +3870,63 @@ export class DaemonClient { this.sendSessionMessageStrict(response); } + async getPaseoAgentProviders( + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.get_providers.request", + }, + timeout: 10000, + }); + } + + async setPaseoAgentProvider( + input: Omit & { requestId?: string }, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "config.paseo_agent.set_provider.request", + name: input.name, + providerType: input.providerType, + options: input.options, + }, + timeout: 30000, + }); + } + + async removePaseoAgentProvider( + name: string, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.remove_provider.request", + name, + }, + timeout: 30000, + }); + } + + async storePaseoAgentChatGptCredential(input: { + providerName: string; + credential: PaseoAgentOAuthCredential; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "config.paseo_agent.store_chatgpt_credential.request", + providerName: input.providerName, + credential: input.credential, + }, + timeout: 30000, + }); + } + async readProjectConfig(repoRoot: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/protocol/src/messages.paseo-agent-config.test.ts b/packages/protocol/src/messages.paseo-agent-config.test.ts new file mode 100644 index 000000000..266513e07 --- /dev/null +++ b/packages/protocol/src/messages.paseo-agent-config.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; + +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +describe("Paseo Agent config RPC schemas", () => { + test("parses provider config requests with providerType outside the message type field", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.set_provider.request", + requestId: "req-set-openrouter", + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-test", + models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }], + }, + }); + + expect(parsed.type).toBe("config.paseo_agent.set_provider.request"); + expect(parsed.providerType).toBe("openrouter"); + }); + + test("parses redacted provider responses without raw secret fields", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.get_providers.response", + payload: { + requestId: "req-get", + defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet", + providers: [ + { + name: "openrouter-main", + providerType: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + ], + error: null, + }, + }); + + expect(parsed.payload.providers[0]?.providerType).toBe("openrouter"); + expect(JSON.stringify(parsed)).not.toContain("apiKey"); + }); + + test("preserves future OAuth credential fields on inbound schema parse", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.store_chatgpt_credential.request", + requestId: "req-oauth", + providerName: "chatgpt", + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + accountId: "acct_123", + futureField: { keep: true }, + }, + }); + + expect(parsed.credential.futureField).toEqual({ keep: true }); + }); + + test("parses ChatGPT provider config separately from credential storage", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.set_provider.request", + requestId: "req-set-chatgpt", + name: "chatgpt", + providerType: "openai-codex", + options: { + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + }); + + expect(parsed.providerType).toBe("openai-codex"); + expect(JSON.stringify(parsed)).not.toContain("access-token"); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 8e726078e..e79698adf 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1922,6 +1922,112 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({ requestId: z.string(), }); +const PaseoAgentProviderTypeSchema = z.enum([ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom", +]); + +const PaseoAgentProviderModelConfigSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1).optional(), + api: z.string().min(1).optional(), + reasoning: z.boolean().optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + }) + .strict(); + +const PaseoAgentSetProviderOptionsSchema = z + .object({ + apiKey: z.string().min(1).optional(), + baseUrl: z.string().url().optional(), + api: z.string().min(1).optional(), + headers: z.record(z.string()).optional(), + authHeader: z.boolean().optional(), + models: z.array(PaseoAgentProviderModelConfigSchema).min(1), + }) + .strict(); + +const PaseoAgentOAuthCredentialSchema = z + .object({ + type: z.literal("oauth"), + access: z.string(), + refresh: z.string(), + expires: z.number(), + accountId: z.string().min(1).optional(), + }) + .passthrough(); + +export const PaseoAgentProviderAuthStateSchema = z + .object({ + kind: z.enum(["api_key", "oauth", "none"]), + configured: z.boolean(), + source: z + .enum(["literal", "env", "default_env", "command", "stored", "refresh_token"]) + .optional(), + hint: z.string().optional(), + }) + .strict(); + +export const RedactedPaseoAgentProviderConfigSchema = z + .object({ + name: z.string().min(1), + providerType: z.enum([ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom", + ]), + baseUrl: z.string().optional(), + api: z.string().optional(), + models: z.array(PaseoAgentProviderModelConfigSchema), + auth: PaseoAgentProviderAuthStateSchema, + available: z.boolean(), + error: z.string().nullable().optional(), + }) + .strict(); + +export const PaseoAgentGetProvidersRequestSchema = z.object({ + type: z.literal("config.paseo_agent.get_providers.request"), + requestId: z.string(), +}); + +export const PaseoAgentSetProviderRequestSchema = z.object({ + type: z.literal("config.paseo_agent.set_provider.request"), + requestId: z.string(), + name: z.string().trim().min(1), + providerType: PaseoAgentProviderTypeSchema, + options: PaseoAgentSetProviderOptionsSchema, +}); + +export const PaseoAgentRemoveProviderRequestSchema = z.object({ + type: z.literal("config.paseo_agent.remove_provider.request"), + requestId: z.string(), + name: z.string().trim().min(1), +}); + +export const PaseoAgentSetDefaultModelRequestSchema = z.object({ + type: z.literal("config.paseo_agent.set_default_model.request"), + requestId: z.string(), + model: z.string().trim().min(1).nullable(), +}); + +export const PaseoAgentStoreChatGptCredentialRequestSchema = z.object({ + type: z.literal("config.paseo_agent.store_chatgpt_credential.request"), + requestId: z.string(), + providerName: z.string().trim().min(1), + credential: PaseoAgentOAuthCredentialSchema, +}); + export const ListCommandsRequestSchema = z.object({ type: z.literal("list_commands_request"), agentId: z.string(), @@ -2073,6 +2179,11 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModelsRequestMessageSchema, ListProviderModesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema, + PaseoAgentGetProvidersRequestSchema, + PaseoAgentSetProviderRequestSchema, + PaseoAgentRemoveProviderRequestSchema, + PaseoAgentSetDefaultModelRequestSchema, + PaseoAgentStoreChatGptCredentialRequestSchema, ListAvailableProvidersRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema, @@ -2364,6 +2475,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. + paseoAgentConfig: z.boolean().optional(), }) .optional(), }) @@ -3870,6 +3983,57 @@ export const ListProviderFeaturesResponseMessageSchema = z.object({ }), }); +export const PaseoAgentGetProvidersResponseSchema = z.object({ + type: z.literal("config.paseo_agent.get_providers.response"), + payload: z.object({ + requestId: z.string(), + defaultModel: z.string().nullable(), + providers: z.array(RedactedPaseoAgentProviderConfigSchema), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentSetProviderResponseSchema = z.object({ + type: z.literal("config.paseo_agent.set_provider.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + provider: RedactedPaseoAgentProviderConfigSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentRemoveProviderResponseSchema = z.object({ + type: z.literal("config.paseo_agent.remove_provider.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + removed: z.boolean(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentSetDefaultModelResponseSchema = z.object({ + type: z.literal("config.paseo_agent.set_default_model.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + defaultModel: z.string().nullable(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentStoreChatGptCredentialResponseSchema = z.object({ + type: z.literal("config.paseo_agent.store_chatgpt_credential.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + providerName: z.string(), + auth: PaseoAgentProviderAuthStateSchema, + error: z.string().nullable(), + }), +}); + const ProviderAvailabilitySchema = z.object({ provider: AgentProviderSchema, available: z.boolean(), @@ -4265,6 +4429,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModelsResponseMessageSchema, ListProviderModesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema, + PaseoAgentGetProvidersResponseSchema, + PaseoAgentSetProviderResponseSchema, + PaseoAgentRemoveProviderResponseSchema, + PaseoAgentSetDefaultModelResponseSchema, + PaseoAgentStoreChatGptCredentialResponseSchema, ListAvailableProvidersResponseSchema, GetProvidersSnapshotResponseMessageSchema, ProvidersSnapshotUpdateMessageSchema, @@ -4397,6 +4566,22 @@ export type ListProviderModesResponseMessage = z.infer< export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; +export type RedactedPaseoAgentProviderConfig = z.infer< + typeof RedactedPaseoAgentProviderConfigSchema +>; +export type PaseoAgentProviderAuthState = z.infer; +export type PaseoAgentOAuthCredential = z.infer; +export type PaseoAgentGetProvidersResponse = z.infer; +export type PaseoAgentSetProviderResponse = z.infer; +export type PaseoAgentRemoveProviderResponse = z.infer< + typeof PaseoAgentRemoveProviderResponseSchema +>; +export type PaseoAgentSetDefaultModelResponse = z.infer< + typeof PaseoAgentSetDefaultModelResponseSchema +>; +export type PaseoAgentStoreChatGptCredentialResponse = z.infer< + typeof PaseoAgentStoreChatGptCredentialResponseSchema +>; export type ListAvailableProvidersResponse = z.infer; export type DaemonGetStatusResponse = z.infer; export type DaemonGetPairingOfferResponse = z.infer; @@ -4473,6 +4658,15 @@ export type ListProviderModesRequestMessage = z.infer; +export type PaseoAgentGetProvidersRequest = z.infer; +export type PaseoAgentSetProviderRequest = z.infer; +export type PaseoAgentRemoveProviderRequest = z.infer; +export type PaseoAgentSetDefaultModelRequest = z.infer< + typeof PaseoAgentSetDefaultModelRequestSchema +>; +export type PaseoAgentStoreChatGptCredentialRequest = z.infer< + typeof PaseoAgentStoreChatGptCredentialRequestSchema +>; export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 640fc4e48..28881c1a2 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1019,6 +1019,46 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { }); }); +describe("ProviderSnapshotManager applyPaseoAgentConfig", () => { + test("refreshes Paseo Agent models without daemon restart", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + paseoAgentConfig: {}, + }); + try { + manager.applyPaseoAgentConfig({ + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-test", + models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }], + }, + }, + }, + }); + + const models = await manager.listModels({ provider: "paseo", wait: true }); + expect(models).toEqual([ + expect.objectContaining({ + provider: "paseo", + id: "openrouter-main/anthropic/claude-3.7-sonnet", + label: "Claude", + }), + ]); + } finally { + manager.destroy(); + } + }); +}); + describe("ProviderSnapshotManager lifecycle", () => { test("on/off attaches and detaches change listeners", () => { const manager = new ProviderSnapshotManager({ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 7af2893fc..e8f3ef23c 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -166,7 +166,7 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private readonly baseProviderOverrides: Record | undefined; - private readonly paseoAgentConfig: PaseoAgentConfig | undefined; + private paseoAgentConfig: PaseoAgentConfig | undefined; private providerRegistry: Record; private providerClients: Record; @@ -378,16 +378,12 @@ export class ProviderSnapshotManager { this.baseProviderOverrides, mutableProviders, ); - this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; + return this.rebuildRegistryAndReconcileSnapshots(); + } - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); - this.emitChange(cwd); - } - - return this.getAgentManagerProviderState(); + applyPaseoAgentConfig(config: PaseoAgentConfig | undefined): AgentManagerProviderState { + this.paseoAgentConfig = config; + return this.rebuildRegistryAndReconcileSnapshots(); } on(event: "change", listener: ProviderSnapshotChangeListener): this { @@ -447,6 +443,19 @@ export class ProviderSnapshotManager { return registry; } + private rebuildRegistryAndReconcileSnapshots(): AgentManagerProviderState { + this.providerRegistry = this.buildRegistry(); + this.providerClients = { ...this.extraClients } as Record; + + for (const cwd of this.snapshots.keys()) { + this.providerLoads.delete(cwd); + this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); + this.emitChange(cwd); + } + + return this.getAgentManagerProviderState(); + } + private resolveParent(parent: ManagedAgent): AgentCreateConfigParent { const definition = this.requireProvider(parent.provider); return { diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts new file mode 100644 index 000000000..e160e6552 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js"; +import { PaseoAgentConfigService } from "./config-service.js"; +import { paseoAgentAuthStoragePath } from "./oauth-store.js"; + +describe("PaseoAgentConfigService", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "paseo-agent-config-service-")); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + test("persists an OpenRouter provider and returns only redacted auth state", () => { + const onConfigChanged = vi.fn(); + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + onConfigChanged, + }); + + const provider = service.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + headers: { Authorization: "Bearer header-secret" }, + models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }], + }, + }); + + const persisted = loadPersistedConfig(home); + expect(persisted.agents?.paseo?.providers?.["openrouter-main"]).toMatchObject({ + type: "openrouter", + options: { apiKey: "sk-secret-openrouter" }, + }); + expect(provider).toMatchObject({ + name: "openrouter-main", + providerType: "openrouter", + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + }); + expect(JSON.stringify(service.getProviders())).not.toContain("sk-secret-openrouter"); + expect(JSON.stringify(service.getProviders())).not.toContain("header-secret"); + expect(onConfigChanged).toHaveBeenCalledWith( + expect.objectContaining({ providers: expect.any(Object) }), + ); + }); + + test("preserves shared config fields when writing agents.paseo", () => { + const logger = createTestLogger(); + savePersistedConfig( + home, + { + daemon: { appendSystemPrompt: "Keep existing daemon settings." }, + app: { baseUrl: "http://localhost:8081" }, + agents: { + providers: { + gemini: { + extends: "acp", + label: "Gemini", + command: ["gemini", "--acp"], + }, + }, + }, + }, + logger, + ); + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger, + }); + + service.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + + const persisted = loadPersistedConfig(home); + expect(persisted.daemon?.appendSystemPrompt).toBe("Keep existing daemon settings."); + expect(persisted.app?.baseUrl).toBe("http://localhost:8081"); + expect(persisted.agents?.providers?.gemini).toMatchObject({ + extends: "acp", + label: "Gemini", + }); + expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe( + "sk-secret-openrouter", + ); + }); + + test("stores ChatGPT OAuth credentials in the Paseo-owned auth store with future fields intact", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + + service.storeChatGptCredential("chatgpt", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + accountId: "acct_123", + futureField: { keep: true }, + }); + + const authPath = paseoAgentAuthStoragePath({ PASEO_HOME: home }); + const stored = JSON.parse(readFileSync(authPath, "utf8")); + expect(stored.chatgpt).toMatchObject({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + futureField: { keep: true }, + }); + expect(authPath).toBe(join(home, "paseo-agent", "auth.json")); + }); + + test("reports ChatGPT auth as stored without returning tokens", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + service.setProvider({ + name: "chatgpt", + providerType: "openai-codex", + options: { + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + }); + service.storeChatGptCredential("chatgpt", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }); + + const providers = service.getProviders(); + expect(providers.providers).toEqual([ + expect.objectContaining({ + name: "chatgpt", + providerType: "openai-codex", + auth: { kind: "oauth", configured: true, source: "stored" }, + available: true, + }), + ]); + expect(JSON.stringify(providers)).not.toContain("access-token"); + expect(JSON.stringify(providers)).not.toContain("refresh-token"); + }); + + test("removes providers and clears a default model owned by that provider", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + service.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + service.setDefaultModel("openrouter-main/anthropic/claude-3.7-sonnet"); + + expect(service.removeProvider("openrouter-main")).toBe(true); + expect(service.getProviders()).toEqual({ defaultModel: null, providers: [] }); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts new file mode 100644 index 000000000..d79735df6 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -0,0 +1,268 @@ +import type { Logger } from "pino"; +import type { + PaseoAgentOAuthCredential, + PaseoAgentProviderAuthState, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; + +import { + loadPersistedConfig, + savePersistedConfig, + type PersistedConfig, +} from "../../../persisted-config.js"; +import { + PaseoAgentConfigSchema, + type PaseoAgentConfig, + type PaseoAgentProviderType, +} from "./config.js"; +import { hasStoredOAuthCredential, storeCodexOAuthCredential } from "./oauth-store.js"; +import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js"; + +interface PaseoAgentConfigServiceOptions { + paseoHome: string; + logger: Logger; + env?: NodeJS.ProcessEnv; + onConfigChanged?: (config: PaseoAgentConfig | undefined) => void; +} + +interface SetProviderInput { + name: string; + providerType: PaseoAgentProviderType; + options: { + apiKey?: string; + baseUrl?: string; + api?: string; + headers?: Record; + authHeader?: boolean; + models: Array<{ + id: string; + label?: string; + api?: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; + }>; + }; +} + +const PROVIDER_DEFAULTS: Record< + PaseoAgentProviderType | "openai-codex", + { baseUrl?: string; api?: string; envVar?: string } +> = { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + envVar: "OPENROUTER_API_KEY", + }, + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + envVar: "OPENAI_API_KEY", + }, + anthropic: { + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + envVar: "ANTHROPIC_API_KEY", + }, + opencode: { + baseUrl: "https://opencode.ai/zen/v1", + api: "openai-completions", + envVar: "OPENCODE_API_KEY", + }, + "openai-compatible": { + api: "openai-completions", + }, + custom: {}, + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + }, +}; + +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; + +function resolveEnv(paseoHome: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return env ?? { ...process.env, PASEO_HOME: paseoHome }; +} + +function authStateForApiKey( + value: string | undefined, + fallbackEnvVar: string | undefined, + env: NodeJS.ProcessEnv, +): PaseoAgentProviderAuthState { + if (!value && fallbackEnvVar) { + return { + kind: "api_key", + configured: Boolean(env[fallbackEnvVar]), + source: "default_env", + hint: fallbackEnvVar, + }; + } + if (!value) { + return { kind: "none", configured: false }; + } + if (value.startsWith("!")) { + return { kind: "api_key", configured: true, source: "command" }; + } + const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); + if (referencedVars.length > 0) { + return { + kind: "api_key", + configured: referencedVars.every((name) => Boolean(env[name])), + source: "env", + hint: referencedVars.join(","), + }; + } + return { kind: "api_key", configured: true, source: "literal" }; +} + +function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {}); +} + +function redactedProviders( + config: PaseoAgentConfig, + env: NodeJS.ProcessEnv, +): RedactedPaseoAgentProviderConfig[] { + return Object.entries(config.providers ?? {}).map(([name, entry]) => { + const defaults = PROVIDER_DEFAULTS[entry.type]; + let auth: PaseoAgentProviderAuthState; + if (entry.type === "openai-codex") { + const hasRefreshToken = + entry.options.refreshToken && + isRefreshTokenExpressionConfigured(entry.options.refreshToken, env); + if (hasRefreshToken) { + auth = { kind: "oauth", configured: true, source: "refresh_token" }; + } else { + const stored = hasStoredOAuthCredential(name, env); + auth = stored + ? { kind: "oauth", configured: true, source: "stored" } + : { kind: "oauth", configured: false }; + } + } else { + auth = authStateForApiKey(entry.options.apiKey, defaults.envVar, env); + } + const provider: RedactedPaseoAgentProviderConfig = { + name, + providerType: entry.type, + models: entry.options.models.map((model) => ({ ...model })), + auth, + available: auth.configured && entry.options.models.length > 0, + error: null, + }; + const baseUrl = entry.options.baseUrl ?? defaults.baseUrl; + if (baseUrl) { + provider.baseUrl = baseUrl; + } + const api = entry.options.api ?? defaults.api; + if (api) { + provider.api = api; + } + return provider; + }); +} + +function mergePaseoAgentConfig( + persisted: PersistedConfig, + paseoConfig: PaseoAgentConfig | undefined, +): PersistedConfig { + return { + ...persisted, + agents: { + ...persisted.agents, + paseo: paseoConfig, + }, + }; +} + +export class PaseoAgentConfigService { + private readonly paseoHome: string; + private readonly logger: Logger; + private readonly env: NodeJS.ProcessEnv; + private readonly onConfigChanged: ((config: PaseoAgentConfig | undefined) => void) | undefined; + + constructor(options: PaseoAgentConfigServiceOptions) { + this.paseoHome = options.paseoHome; + this.logger = options.logger.child({ module: "paseo-agent-config-service" }); + this.env = resolveEnv(options.paseoHome, options.env); + this.onConfigChanged = options.onConfigChanged; + } + + getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } { + const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); + return { + defaultModel: config.defaultModel ?? null, + providers: redactedProviders(config, this.env), + }; + } + + setProvider(input: SetProviderInput): RedactedPaseoAgentProviderConfig { + const next = this.updateConfig((current) => + PaseoAgentConfigSchema.parse({ + ...current, + providers: { + ...current.providers, + [input.name]: { + type: input.providerType, + options: input.options, + }, + }, + }), + ); + return this.requireRedactedProvider(next, input.name); + } + + removeProvider(name: string): boolean { + let removed = false; + this.updateConfig((current) => { + const providers = { ...current.providers }; + removed = Object.prototype.hasOwnProperty.call(providers, name); + delete providers[name]; + return PaseoAgentConfigSchema.parse({ + ...current, + ...(Object.keys(providers).length > 0 ? { providers } : { providers: undefined }), + ...(current.defaultModel?.startsWith(`${name}/`) ? { defaultModel: undefined } : {}), + }); + }); + return removed; + } + + setDefaultModel(model: string | null): string | null { + const next = this.updateConfig((current) => + PaseoAgentConfigSchema.parse({ + ...current, + ...(model ? { defaultModel: model } : { defaultModel: undefined }), + }), + ); + return next.defaultModel ?? null; + } + + storeChatGptCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { + storeCodexOAuthCredential({ + providerInstance: providerName, + credential, + env: this.env, + }); + const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); + this.onConfigChanged?.(config); + } + + private requireRedactedProvider( + config: PaseoAgentConfig, + name: string, + ): RedactedPaseoAgentProviderConfig { + const provider = redactedProviders(config, this.env).find((entry) => entry.name === name); + if (!provider) { + throw new Error(`Paseo Agent provider '${name}' was not found after update.`); + } + return provider; + } + + private updateConfig(update: (current: PaseoAgentConfig) => PaseoAgentConfig): PaseoAgentConfig { + const persisted = loadPersistedConfig(this.paseoHome, this.logger); + const next = update(readPaseoAgentConfig(persisted)); + savePersistedConfig(this.paseoHome, mergePaseoAgentConfig(persisted, next), this.logger); + this.onConfigChanged?.(next); + return next; + } +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts index 596aa354c..95cd1302b 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts @@ -30,6 +30,14 @@ type BrowserLogin = (options: { onProgress?: (message: string) => void; }) => Promise; +export type StoredCodexOAuthCredential = { + type: "oauth"; + access: string; + refresh: string; + expires: number; + accountId?: string; +} & Record; + /** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */ export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string { const base = env.PASEO_HOME ?? join(homedir(), ".paseo"); @@ -70,6 +78,22 @@ export function hasStoredOAuthCredential( } } +/** + * Store a credential obtained by a remote-safe client-side OAuth flow into the + * daemon's Paseo-owned AuthStorage. The caller supplies a stable wire shape, not + * Pi types, and this helper never reads or writes foreign auth files. + */ +export function storeCodexOAuthCredential(options: { + providerInstance: string; + credential: StoredCodexOAuthCredential; + env?: NodeJS.ProcessEnv; +}): { path: string } { + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, options.credential); + return { path }; +} + /** * Run Pi's ChatGPT/Codex device-code OAuth login and persist the resulting credential * into the Paseo-owned store under `providerInstance`. The `login` dependency defaults @@ -106,6 +130,23 @@ export async function loginAndStoreCodexBrowser(options: { env?: NodeJS.ProcessEnv; login?: BrowserLogin; }): Promise<{ path: string }> { + const credential = await loginCodexBrowser(options); + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, credential); + return { path }; +} + +/** + * Run Pi's browser OAuth flow and return the credential without storing it locally. + * CLI remote login uses this so the selected daemon remains the owner of persisted auth. + */ +export async function loginCodexBrowser(options: { + onAuthUrl: (url: string, instructions?: string) => void; + promptForCode?: (message: string) => Promise; + onProgress?: (message: string) => void; + login?: BrowserLogin; +}): Promise { const login = options.login ?? (loginOpenAICodex as BrowserLogin); const credentials = await login({ onAuth: (info) => options.onAuthUrl(info.url, info.instructions), @@ -117,8 +158,5 @@ export async function loginAndStoreCodexBrowser(options: { return options.promptForCode(prompt.message); }, }); - const path = paseoAgentAuthStoragePath(options.env); - const authStorage = AuthStorage.create(path); - authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); - return { path }; + return { type: "oauth", ...credentials }; } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 9d741da84..07c172903 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; +import { + DaemonConfigStore, + applyMutableProviderConfigToOverrides, + type MutableDaemonConfigPatch, +} from "./daemon-config-store.js"; import { loadPersistedConfig } from "./persisted-config.js"; describe("applyMutableProviderConfigToOverrides", () => { @@ -119,6 +123,90 @@ describe("DaemonConfigStore", () => { }); }); + test("generic mutable config strips dedicated Paseo Agent config instead of echoing secrets", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const broadcasts: unknown[] = []; + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + }, + undefined, + ); + store.onChange((config) => broadcasts.push(config)); + + const secretBearingPatch: MutableDaemonConfigPatch & { agents: unknown } = { + agents: { + paseo: { + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + headers: { Authorization: "Bearer header-secret" }, + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }, + }; + const returned = store.patch(secretBearingPatch); + + const returnedJson = JSON.stringify(returned); + const broadcastJson = JSON.stringify(broadcasts); + expect(returnedJson).not.toContain("sk-secret-openrouter"); + expect(returnedJson).not.toContain("header-secret"); + expect(broadcastJson).not.toContain("sk-secret-openrouter"); + expect(broadcastJson).not.toContain("header-secret"); + expect(loadPersistedConfig(paseoHome).agents?.paseo).toBeUndefined(); + }); + + test("mutable provider patches preserve existing dedicated Paseo Agent config on disk", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const initial = loadPersistedConfig(paseoHome); + initial.agents = { + paseo: { + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }; + writeFileSync(path.join(paseoHome, "config.json"), JSON.stringify(initial, null, 2) + "\n"); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + }, + undefined, + ); + + store.patch({ + providers: { + claude: { enabled: false }, + }, + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe( + "sk-secret-openrouter", + ); + expect(persisted.agents?.providers?.claude).toEqual({ enabled: false }); + }); + test("patch persists append system prompt into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 1876322e4..11e28ff7f 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -62,6 +62,22 @@ function isEqualValue(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } +function stripDedicatedPaseoAgentConfig>(config: T): T { + const agents = config.agents; + if (!isRecord(agents) || !Object.prototype.hasOwnProperty.call(agents, "paseo")) { + return config; + } + + const { paseo: _paseo, ...remainingAgents } = agents; + const next: Record = { ...config }; + if (Object.keys(remainingAgents).length > 0) { + next.agents = remainingAgents; + } else { + delete next.agents; + } + return next as T; +} + export function applyMutableProviderConfigToOverrides( baseOverrides: Record | undefined, mutableProviders: MutableDaemonConfig["providers"] | undefined, @@ -91,7 +107,7 @@ export class DaemonConfigStore { constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) { this.paseoHome = paseoHome; this.logger = getLogger(logger); - this.current = MutableDaemonConfigSchema.parse(initial); + this.current = stripDedicatedPaseoAgentConfig(MutableDaemonConfigSchema.parse(initial)); } public get(): MutableDaemonConfig { @@ -99,8 +115,12 @@ export class DaemonConfigStore { } public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial); - const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)); + const parsedPatch = stripDedicatedPaseoAgentConfig( + MutableDaemonConfigPatchSchema.parse(partial), + ); + const next = stripDedicatedPaseoAgentConfig( + MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)), + ); const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => { return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path)); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 221a4b7da..9719ed312 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -50,10 +50,12 @@ export { // Paseo Agent (ChatGPT/Codex) OAuth login + Paseo-owned credential store export { + loginCodexBrowser, loginAndStoreCodexBrowser, loginAndStoreCodex, paseoAgentAuthStoragePath, type CodexDeviceCodeInfo, + type StoredCodexOAuthCredential, } from "./agent/providers/paseo-agent/oauth-store.js"; // Provider binary resolution diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4e4f4c1bc..4462fb22f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ import { import { AgentManager } from "./agent/agent-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { PaseoAgentConfigService } from "./agent/providers/paseo-agent/config-service.js"; import type { AgentManagerEvent, AgentTimelineCursor, @@ -1652,6 +1653,16 @@ export class Session { return this.providerCatalogSession.handleListProviderModesRequest(msg); case "list_provider_features_request": return this.providerCatalogSession.handleListProviderFeaturesRequest(msg); + case "config.paseo_agent.get_providers.request": + return this.handlePaseoAgentGetProvidersRequest(msg); + case "config.paseo_agent.set_provider.request": + return this.handlePaseoAgentSetProviderRequest(msg); + case "config.paseo_agent.remove_provider.request": + return this.handlePaseoAgentRemoveProviderRequest(msg); + case "config.paseo_agent.set_default_model.request": + return this.handlePaseoAgentSetDefaultModelRequest(msg); + case "config.paseo_agent.store_chatgpt_credential.request": + return this.handlePaseoAgentStoreChatGptCredentialRequest(msg); case "list_available_providers_request": return this.providerCatalogSession.handleListAvailableProvidersRequest(msg); case "get_providers_snapshot_request": @@ -1667,6 +1678,183 @@ export class Session { } } + private createPaseoAgentConfigService(): PaseoAgentConfigService { + return new PaseoAgentConfigService({ + paseoHome: this.paseoHome, + logger: this.sessionLogger, + onConfigChanged: (config) => { + const state = this.providerSnapshotManager.applyPaseoAgentConfig(config); + this.agentManager.updateProviderRegistry(state); + }, + }); + } + + private async refreshPaseoAgentRuntimeSnapshot(): Promise { + await this.providerSnapshotManager.refreshSettingsSnapshot({ providers: ["paseo"] }); + } + + private async handlePaseoAgentGetProvidersRequest( + msg: Extract, + ): Promise { + try { + const result = this.createPaseoAgentConfigService().getProviders(); + this.emit({ + type: "config.paseo_agent.get_providers.response", + payload: { + requestId: msg.requestId, + defaultModel: result.defaultModel, + providers: result.providers, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to read Paseo Agent providers"); + this.emit({ + type: "config.paseo_agent.get_providers.response", + payload: { + requestId: msg.requestId, + defaultModel: null, + providers: [], + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentSetProviderRequest( + msg: Extract, + ): Promise { + try { + const provider = this.createPaseoAgentConfigService().setProvider({ + name: msg.name, + providerType: msg.providerType, + options: msg.options, + }); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.set_provider.response", + payload: { + requestId: msg.requestId, + success: true, + provider, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.name, providerType: msg.providerType }, + "Failed to set Paseo Agent provider", + ); + this.emit({ + type: "config.paseo_agent.set_provider.response", + payload: { + requestId: msg.requestId, + success: false, + provider: null, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentRemoveProviderRequest( + msg: Extract, + ): Promise { + try { + const removed = this.createPaseoAgentConfigService().removeProvider(msg.name); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.remove_provider.response", + payload: { + requestId: msg.requestId, + success: true, + removed, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.name }, + "Failed to remove Paseo Agent provider", + ); + this.emit({ + type: "config.paseo_agent.remove_provider.response", + payload: { + requestId: msg.requestId, + success: false, + removed: false, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentSetDefaultModelRequest( + msg: Extract, + ): Promise { + try { + const defaultModel = this.createPaseoAgentConfigService().setDefaultModel(msg.model); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.set_default_model.response", + payload: { + requestId: msg.requestId, + success: true, + defaultModel, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to set Paseo Agent default model"); + this.emit({ + type: "config.paseo_agent.set_default_model.response", + payload: { + requestId: msg.requestId, + success: false, + defaultModel: null, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentStoreChatGptCredentialRequest( + msg: Extract< + SessionInboundMessage, + { type: "config.paseo_agent.store_chatgpt_credential.request" } + >, + ): Promise { + try { + this.createPaseoAgentConfigService().storeChatGptCredential(msg.providerName, msg.credential); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.store_chatgpt_credential.response", + payload: { + requestId: msg.requestId, + success: true, + providerName: msg.providerName, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.providerName }, + "Failed to store Paseo Agent ChatGPT credential", + ); + this.emit({ + type: "config.paseo_agent.store_chatgpt_credential.response", + payload: { + requestId: msg.requestId, + success: false, + providerName: msg.providerName, + auth: { kind: "oauth", configured: false }, + error: getErrorMessage(error), + }, + }); + } + } + private dispatchTerminalMessage(msg: SessionInboundMessage): Promise | undefined { if (msg.type === "start_workspace_script_request") { return this.handleStartWorkspaceScriptRequest(msg); diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index f7ddbdd95..4ebf2080e 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -163,6 +163,7 @@ export interface ProviderSnapshotManagerSpies { typeof vi.fn<[AgentProvider], Promise> >; applyMutableProviderConfig: ReturnType>; + applyPaseoAgentConfig: ReturnType>; destroy: ReturnType>; } @@ -204,6 +205,10 @@ export function createProviderSnapshotManagerStub(): { providerDefinitions: {}, clients: {}, })); + const applyPaseoAgentConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({ + providerDefinitions: {}, + clients: {}, + })); const on = vi.fn(); const off = vi.fn(); const destroy = vi.fn<[], void>(); @@ -224,6 +229,7 @@ export function createProviderSnapshotManagerStub(): { resolveDefaultModel, getProviderDiagnostic, applyMutableProviderConfig, + applyPaseoAgentConfig, on, off, destroy, @@ -249,6 +255,7 @@ export function createProviderSnapshotManagerStub(): { resolveDefaultModel, getProviderDiagnostic, applyMutableProviderConfig, + applyPaseoAgentConfig, destroy, }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4f048637d..7b5470e7a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1225,6 +1225,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. + paseoAgentConfig: true, }, }; } @@ -1714,17 +1716,20 @@ export class VoiceAssistantWebSocketServer { const { ws, data, error, log } = params; const err = error instanceof Error ? error : new Error(String(error)); const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data); + const redactedParsedPayload = redactPaseoAgentConfigSecrets(parsedPayload); + const redactedRawPayload = + redactedParsedPayload === parsedPayload ? rawPayload : JSON.stringify(redactedParsedPayload); const trimmedRawPayload = - typeof rawPayload === "string" && rawPayload.length > 2000 - ? `${rawPayload.slice(0, 2000)}... (truncated)` - : rawPayload; + typeof redactedRawPayload === "string" && redactedRawPayload.length > 2000 + ? `${redactedRawPayload.slice(0, 2000)}... (truncated)` + : redactedRawPayload; log.error( { err, rawPayload: trimmedRawPayload, - parsedPayload, + parsedPayload: redactedParsedPayload, }, "Failed to parse/handle message", ); @@ -2297,3 +2302,48 @@ function extractRequestInfoFromUnknownWsInbound( return null; } + +function redactPaseoAgentConfigSecrets(payload: unknown): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + const record = payload as Record; + if (record.type !== "session" || !record.message || typeof record.message !== "object") { + return payload; + } + + const message = record.message as Record; + if (message.type === "config.paseo_agent.set_provider.request") { + const options = message.options; + if (!options || typeof options !== "object" || Array.isArray(options)) { + return payload; + } + return { + ...record, + message: { + ...message, + options: { + ...(options as Record), + ...(Object.prototype.hasOwnProperty.call(options, "apiKey") + ? { apiKey: "" } + : {}), + ...(Object.prototype.hasOwnProperty.call(options, "headers") + ? { headers: "" } + : {}), + }, + }, + }; + } + + if (message.type === "config.paseo_agent.store_chatgpt_credential.request") { + return { + ...record, + message: { + ...message, + credential: "", + }, + }; + } + + return payload; +}