diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 8863ba26b3..688974bd1b 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -202,7 +202,10 @@ provider instance and clears `defaultModel` if it pointed at the removed instanc The app uses the same catalog and config RPCs as the CLI. In Settings, open the host settings, then the Paseo Agent provider section. The app fetches the catalog from the connected daemon, shows catalog entries in the picker, and renders either an API-key -form or an OAuth sign-in flow from the entry's `auth.kind`. +form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two +daemon-run starts: browser redirect for the local fast path and device code for +remote-safe authorization. Relay connections present device code first; direct TCP does +not prove same-machine by itself, so both options remain available. Configured rows show the redacted provider state from the daemon: label, provider type, models, availability, and auth state. The app gates this UI on @@ -229,9 +232,20 @@ RPC names use dotted namespaces: | `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` | | `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` | -Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names, and -wire API names are strings, not closed enums. Old clients can parse new catalog entries, -and new clients can show "update host" only when the daemon lacks the catalog feature. +`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and +`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can +complete the localhost redirect on the daemon host. Browser mode returns +`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback +listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve, +stores the credential, and returns the redacted `auth` state. Device-code mode returns +`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?, +expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the +provider authorizes. + +Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names, +OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse +new catalog entries, and new clients can show "update host" only when the daemon lacks the +catalog feature. ## Adding a catalog entry diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts index f3d36d6632..05de2b2f24 100644 --- a/packages/app/e2e/helpers/paseo-agent.ts +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -75,7 +75,7 @@ export async function startOAuthProviderSignIn( await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible(); await page.getByLabel("Provider name").fill(provider.name); - await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await page.getByRole("button", { name: "Use a code instead", exact: true }).click(); await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible(); await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible(); } diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts index 63f59a22a9..15b08ed85e 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts @@ -12,6 +12,7 @@ import { parsePaseoAgentModelIds, paseoAgentAuthBadge, paseoAgentProviderLabel, + preferredPaseoAgentOAuthMode, } from "./paseo-agent-settings-sheet-model"; function catalogEntry(overrides: Partial): PaseoAgentCatalogEntry { @@ -76,6 +77,14 @@ describe("paseo-agent-settings-sheet-model", () => { expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false); }); + it("prefers device-code OAuth only for a relay connection", () => { + expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code"); + expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser"); + expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser"); + expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser"); + expect(preferredPaseoAgentOAuthMode(null)).toBe("browser"); + }); + it("parses model ids from comma and newline separated input", () => { expect( parsePaseoAgentModelIds(` diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.ts index 4dadf2541b..575a02c351 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.ts @@ -22,6 +22,18 @@ export interface PaseoAgentAuthBadge { variant: "success" | "error" | "muted"; } +export type PaseoAgentOAuthMode = "browser" | "device_code"; + +export interface PaseoAgentOAuthConnectionSignal { + type: string; +} + +export function preferredPaseoAgentOAuthMode( + activeConnection: PaseoAgentOAuthConnectionSignal | null, +): PaseoAgentOAuthMode { + return activeConnection?.type === "relay" ? "device_code" : "browser"; +} + export function getPaseoAgentApiKeyAuth( entry: PaseoAgentCatalogEntry, ): PaseoAgentApiKeyAuthManifest | null { diff --git a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx index a8f04132ac..6e20c93628 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx @@ -28,11 +28,11 @@ interface PaseoAgentProvidersHookMock { setProvider: ( input: PaseoAgentSetProviderInput, ) => Promise; - startOAuth: (name: string) => Promise; + startOAuth: (name: string, mode?: string) => Promise; completeOAuth: (name: string) => Promise; } -const { hookState, theme } = vi.hoisted(() => { +const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => { const initialHookState: { current: PaseoAgentProvidersHookMock } = { current: { supported: true, @@ -69,6 +69,14 @@ const { hookState, theme } = vi.hoisted(() => { return { hookState: initialHookState, + hostRuntimeSnapshot: { + current: { + activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" }, + } as { + activeConnection: { type: string; endpoint: string; display: string } | null; + } | null, + }, + openExternalUrls: [] as string[], theme: { spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, borderRadius: { md: 6, lg: 8 }, @@ -268,6 +276,16 @@ vi.mock("@/hooks/use-paseo-agent-providers", () => ({ usePaseoAgentProviders: () => hookState.current, })); +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current, +})); + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: async (url: string) => { + openExternalUrls.push(url); + }, +})); + import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet"; function catalogEntry(overrides: Partial): PaseoAgentCatalogEntry { @@ -327,6 +345,10 @@ function resetHookState() { auth: { kind: "oauth", configured: true }, error: null, })); + hostRuntimeSnapshot.current = { + activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" }, + }; + openExternalUrls.splice(0); } describe("PaseoAgentSettingsSheet", () => { @@ -421,6 +443,41 @@ describe("PaseoAgentSettingsSheet", () => { }); }); + it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => { + hookState.current.catalog = [ + catalogEntry({ + id: "catalog-login", + label: "Catalog Login", + auth: { kind: "oauth", flow: "login-flow" }, + }), + ]; + hookState.current.startOAuth = vi.fn(async () => ({ + requestId: "oauth-start", + success: true, + name: "catalog-login", + authorization: { + kind: "auth_url", + url: "https://login.example.test/oauth/authorize?state=abc", + instructions: "Open the sign-in page", + }, + error: null, + })); + + renderSheet(); + fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); + fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login")); + fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" })); + + await waitFor(() => { + expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]); + expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login", "browser"); + }); + expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy(); + expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe( + "https://login.example.test/oauth/authorize?state=abc", + ); + }); + it("renders a device-code oauth authorization and completes it", async () => { hookState.current.catalog = [ catalogEntry({ @@ -447,7 +504,7 @@ describe("PaseoAgentSettingsSheet", () => { renderSheet(); fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login")); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + fireEvent.click(screen.getByRole("button", { name: "Use a code instead" })); expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123"); expect(screen.getByText("https://login.example.test/device")).toBeTruthy(); @@ -461,11 +518,38 @@ describe("PaseoAgentSettingsSheet", () => { models: [{ id: "alpha-fast", label: "Alpha Fast" }], }, } satisfies PaseoAgentSetProviderInput); - expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login"); + expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login", "device_code"); expect(hookState.current.completeOAuth).toHaveBeenCalledWith("catalog-login"); }); }); + it("offers device-code first over relay while keeping browser available", () => { + hostRuntimeSnapshot.current = { + activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" }, + }; + hookState.current.catalog = [ + catalogEntry({ + id: "catalog-login", + label: "Catalog Login", + auth: { kind: "oauth", flow: "login-flow" }, + }), + ]; + + renderSheet(); + fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); + fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login")); + + const actionButtons = screen + .getByTestId("paseo-agent-provider-form") + .querySelectorAll("button"); + expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([ + "Providers", + "Cancel", + "Use a code instead", + "Sign in with browser", + ]); + }); + it("shows auth-state badges from configured instances", () => { hookState.current.catalog = [ catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }), diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx index ae2c947167..044367fd6f 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -17,14 +17,17 @@ import { ExternalLink } from "@/components/ui/external-link"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/ui/status-badge"; import { usePaseoAgentProviders } from "@/hooks/use-paseo-agent-providers"; +import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import type { PaseoAgentOAuthCompleteResult, PaseoAgentOAuthStartResult, PaseoAgentSetProviderInput, } from "@/hooks/use-paseo-agent-providers"; import type { Theme } from "@/styles/theme"; +import { openExternalUrl } from "@/utils/open-external-url"; import { type PaseoAgentApiKeyAuthManifest, + type PaseoAgentOAuthMode, createPaseoAgentProviderInput, getPaseoAgentApiKeyAuth, getPaseoAgentOAuthAuth, @@ -32,6 +35,7 @@ import { parsePaseoAgentModelIds, paseoAgentAuthBadge, paseoAgentProviderLabel, + preferredPaseoAgentOAuthMode, } from "./paseo-agent-settings-sheet-model"; interface PaseoAgentSettingsSheetProps { @@ -59,10 +63,19 @@ type AddSheetState = type OAuthState = | { status: "idle" } - | { status: "authorizing"; authorization: PaseoAgentOAuthStartResult["authorization"] } - | { status: "completing"; authorization: PaseoAgentOAuthStartResult["authorization"] } + | { + status: "authorizing"; + mode: PaseoAgentOAuthMode; + authorization: PaseoAgentOAuthStartResult["authorization"]; + } + | { + status: "completing"; + mode: PaseoAgentOAuthMode; + authorization: PaseoAgentOAuthStartResult["authorization"]; + } | { status: "error"; + mode: PaseoAgentOAuthMode; message: string; authorization: PaseoAgentOAuthStartResult["authorization"]; }; @@ -91,10 +104,11 @@ interface ProviderFormActionsProps { canComplete: boolean; hasApiKeyAuth: boolean; hasOAuthAuth: boolean; + preferredOAuthMode: PaseoAgentOAuthMode; oauthStatus: OAuthState["status"]; onClose: () => void; onSubmitApiKey: () => void; - onStartOAuth: () => void; + onStartOAuth: (mode: PaseoAgentOAuthMode) => void; onCompleteOAuth: () => void; } @@ -515,6 +529,7 @@ function ProviderFormActions({ canComplete, hasApiKeyAuth, hasOAuthAuth, + preferredOAuthMode, oauthStatus, onClose, onSubmitApiKey, @@ -525,6 +540,54 @@ function ProviderFormActions({ const isOAuthError = oauthStatus === "error"; const isOAuthCompleting = oauthStatus === "completing"; const showOAuthComplete = hasOAuthAuth && !isOAuthIdle && !isOAuthError; + const handleStartBrowserOAuth = useCallback(() => { + onStartOAuth("browser"); + }, [onStartOAuth]); + const handleStartDeviceCodeOAuth = useCallback(() => { + onStartOAuth("device_code"); + }, [onStartOAuth]); + const handleRetryOAuth = useCallback(() => { + onStartOAuth(preferredOAuthMode); + }, [onStartOAuth, preferredOAuthMode]); + const browserButton = ( + + ); + const deviceCodeButton = ( + + ); + let oauthStartActions: React.ReactNode = null; + if (hasOAuthAuth && isOAuthIdle) { + oauthStartActions = + preferredOAuthMode === "device_code" ? ( + <> + {deviceCodeButton} + {browserButton} + + ) : ( + <> + {browserButton} + {deviceCodeButton} + + ); + } return ( @@ -543,23 +606,12 @@ function ProviderFormActions({ {saving ? "Saving..." : "Save provider"} ) : null} - {hasOAuthAuth && isOAuthIdle ? ( - - ) : null} + {oauthStartActions} {hasOAuthAuth && isOAuthError ? (