feat(paseo-agent): add browser OAuth start mode

Default daemon-run OAuth to browser mode while keeping device-code explicit for remote-safe setup.
This commit is contained in:
Mohamed Boudra
2026-07-02 14:51:26 +02:00
parent 269aa53c27
commit 6a2b5ea5ea
15 changed files with 575 additions and 120 deletions
+18 -4
View File
@@ -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
+1 -1
View File
@@ -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();
}
@@ -12,6 +12,7 @@ import {
parsePaseoAgentModelIds,
paseoAgentAuthBadge,
paseoAgentProviderLabel,
preferredPaseoAgentOAuthMode,
} from "./paseo-agent-settings-sheet-model";
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): 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(`
@@ -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 {
@@ -28,11 +28,11 @@ interface PaseoAgentProvidersHookMock {
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string) => Promise<PaseoAgentOAuthStartResult>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
}
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>): 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" }),
@@ -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 = (
<Button
variant={preferredOAuthMode === "browser" ? "default" : "outline"}
size="sm"
onPress={handleStartBrowserOAuth}
disabled={!canSubmit}
loading={saving}
testID="paseo-agent-oauth-start-browser"
>
{saving ? "Starting..." : "Sign in with browser"}
</Button>
);
const deviceCodeButton = (
<Button
variant={preferredOAuthMode === "device_code" ? "default" : "outline"}
size="sm"
onPress={handleStartDeviceCodeOAuth}
disabled={!canSubmit}
loading={saving}
testID="paseo-agent-oauth-start-device-code"
>
{saving ? "Starting..." : "Use a code instead"}
</Button>
);
let oauthStartActions: React.ReactNode = null;
if (hasOAuthAuth && isOAuthIdle) {
oauthStartActions =
preferredOAuthMode === "device_code" ? (
<>
{deviceCodeButton}
{browserButton}
</>
) : (
<>
{browserButton}
{deviceCodeButton}
</>
);
}
return (
<View style={styles.formActions}>
@@ -543,23 +606,12 @@ function ProviderFormActions({
{saving ? "Saving..." : "Save provider"}
</Button>
) : null}
{hasOAuthAuth && isOAuthIdle ? (
<Button
variant="default"
size="sm"
onPress={onStartOAuth}
disabled={!canSubmit}
loading={saving}
testID="paseo-agent-oauth-start"
>
{saving ? "Starting..." : "Sign in"}
</Button>
) : null}
{oauthStartActions}
{hasOAuthAuth && isOAuthError ? (
<Button
variant="outline"
size="sm"
onPress={onStartOAuth}
onPress={handleRetryOAuth}
disabled={!canSubmit}
loading={saving}
testID="paseo-agent-oauth-retry"
@@ -593,6 +645,7 @@ function PaseoAgentProviderFormSheet({
setProvider,
startOAuth,
completeOAuth,
preferredOAuthMode,
}: {
entry: PaseoAgentCatalogEntry | null;
visible: boolean;
@@ -603,8 +656,9 @@ function PaseoAgentProviderFormSheet({
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string) => Promise<PaseoAgentOAuthStartResult>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
preferredOAuthMode: PaseoAgentOAuthMode;
}) {
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState("");
@@ -672,38 +726,51 @@ function PaseoAgentProviderFormSheet({
.finally(() => setSaving(false));
}, [apiKey, canSubmit, entry, hasCatalogModels, modelIds, onClose, setProvider, trimmedName]);
const handleStartOAuth = useCallback(() => {
if (!entry || !canSubmit) return;
setError(null);
setSaving(true);
setOAuthState({ status: "idle" });
void setProvider(
createPaseoAgentProviderInput({
entry,
name: trimmedName,
modelIds: hasCatalogModels ? undefined : modelIds,
}),
)
.then(() => startOAuth(trimmedName))
.then((result) => {
setOAuthState({ status: "authorizing", authorization: result.authorization });
return undefined;
})
.catch((err: unknown) => {
setOAuthState({
status: "error",
message: describeError(err, "Failed to start sign in"),
authorization: null,
});
})
.finally(() => setSaving(false));
}, [canSubmit, entry, hasCatalogModels, modelIds, setProvider, startOAuth, trimmedName]);
const handleStartOAuth = useCallback(
(mode: PaseoAgentOAuthMode) => {
if (!entry || !canSubmit) return;
setError(null);
setSaving(true);
setOAuthState({ status: "idle" });
let authorization: PaseoAgentOAuthStartResult["authorization"] = null;
void setProvider(
createPaseoAgentProviderInput({
entry,
name: trimmedName,
modelIds: hasCatalogModels ? undefined : modelIds,
}),
)
.then(() => startOAuth(trimmedName, mode))
.then(async (result) => {
authorization = result.authorization;
setOAuthState({ status: "authorizing", mode, authorization });
if (mode === "browser" && authorization?.kind === "auth_url") {
const url = getAuthorizationUrl(authorization);
if (url) {
await openExternalUrl(url);
}
}
return undefined;
})
.catch((err: unknown) => {
setOAuthState({
status: "error",
mode,
message: describeError(err, "Failed to start sign in"),
authorization,
});
})
.finally(() => setSaving(false));
},
[canSubmit, entry, hasCatalogModels, modelIds, setProvider, startOAuth, trimmedName],
);
const handleCompleteOAuth = useCallback(() => {
if (!canSubmit || !canComplete) return;
setError(null);
setSaving(true);
setOAuthState({ status: "completing", authorization: activeAuthorization });
const mode = oauthState.status === "authorizing" ? oauthState.mode : preferredOAuthMode;
setOAuthState({ status: "completing", mode, authorization: activeAuthorization });
void completeOAuth(trimmedName)
.then(() => {
onClose();
@@ -712,12 +779,22 @@ function PaseoAgentProviderFormSheet({
.catch((err: unknown) => {
setOAuthState({
status: "error",
mode,
message: describeError(err, "Failed to complete sign in"),
authorization: activeAuthorization,
});
})
.finally(() => setSaving(false));
}, [activeAuthorization, canComplete, canSubmit, completeOAuth, onClose, trimmedName]);
}, [
activeAuthorization,
canComplete,
canSubmit,
completeOAuth,
oauthState,
onClose,
preferredOAuthMode,
trimmedName,
]);
if (!entry) {
return null;
@@ -784,6 +861,7 @@ function PaseoAgentProviderFormSheet({
canComplete={canComplete}
hasApiKeyAuth={Boolean(apiKeyAuth)}
hasOAuthAuth={Boolean(oauthAuth)}
preferredOAuthMode={preferredOAuthMode}
oauthStatus={oauthState.status}
onClose={onClose}
onSubmitApiKey={handleSubmitApiKey}
@@ -813,6 +891,8 @@ export function PaseoAgentSettingsSheet({
startOAuth,
completeOAuth,
} = usePaseoAgentProviders(serverId);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
const preferredOAuthMode = preferredPaseoAgentOAuthMode(activeConnection);
const [addSheet, setAddSheet] = useState<AddSheetState>({ kind: "closed" });
useEffect(() => {
@@ -942,6 +1022,7 @@ export function PaseoAgentSettingsSheet({
setProvider={setProvider}
startOAuth={startOAuth}
completeOAuth={completeOAuth}
preferredOAuthMode={preferredOAuthMode}
/>
) : null}
</>
@@ -44,7 +44,7 @@ interface UsePaseoAgentProvidersResult {
setProvider: (
input: PaseoAgentSetProviderInput,
) => Promise<RedactedPaseoAgentProviderConfig | null>;
startOAuth: (name: string) => Promise<PaseoAgentOAuthStartResult>;
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
}
@@ -121,11 +121,11 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
);
const startOAuthMutation = useMutation({
mutationFn: async (name: string) => {
mutationFn: async (input: { name: string; mode?: string }) => {
if (!client) {
throw new Error(hostDisconnectedMessage);
}
const result = await client.startPaseoAgentOAuth(name);
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
if (!result.success) {
throw new Error(result.error ?? saveProviderFailedMessage);
}
@@ -134,7 +134,10 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr
});
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
const startOAuth = useCallback((name: string) => startOAuthAsync(name), [startOAuthAsync]);
const startOAuth = useCallback(
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
[startOAuthAsync],
);
const completeOAuthMutation = useMutation({
mutationFn: async (name: string) => {
+19 -9
View File
@@ -20,7 +20,10 @@ interface RecordingClientInput {
providerType: string;
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => Promise<unknown>;
startOAuth?: (name: string) => Promise<unknown>;
startOAuth?: (
name: string,
options?: { mode?: string; requestId?: string } | string,
) => Promise<unknown>;
completeOAuth?: (name: string) => Promise<unknown>;
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
}
@@ -59,8 +62,11 @@ function createClient(input: RecordingClientInput) {
error: null,
};
},
startPaseoAgentOAuth: async (name: string) =>
input.startOAuth?.(name) ?? {
startPaseoAgentOAuth: async (
name: string,
options?: { mode?: string; requestId?: string } | string,
) =>
input.startOAuth?.(name, options) ?? {
requestId: "oauth-start-1",
success: true,
name,
@@ -443,8 +449,10 @@ describe("provider add", () => {
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
startOAuth: async (name) => {
oauthCalls.push(`start:${name}`);
startOAuth: async (name, options) => {
oauthCalls.push(
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
);
return {
requestId: "start-1",
success: true,
@@ -471,7 +479,7 @@ describe("provider add", () => {
}),
});
expect(oauthCalls).toEqual(["start:beta-oauth", "complete:beta-oauth"]);
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
expect(output.join("\n")).toContain("ABCD-EFGH");
expect(output.join("\n")).toContain("https://auth.example.test/device");
expect(result.data.auth).toBe("Connected");
@@ -500,8 +508,10 @@ describe("provider add", () => {
connectDaemon: async () =>
createClient({
catalog: [oauthEntry()],
startOAuth: async (name) => {
oauthCalls.push(`start:${name}`);
startOAuth: async (name, options) => {
oauthCalls.push(
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
);
return {
requestId: "start-1",
success: true,
@@ -527,7 +537,7 @@ describe("provider add", () => {
}),
});
expect(oauthCalls).toEqual(["start:beta-oauth", "complete:beta-oauth"]);
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
expect(output.join("\n")).toContain("Browser could not be opened");
expect(output.join("\n")).toContain("WXYZ-1234");
});
+1 -1
View File
@@ -351,7 +351,7 @@ async function runDaemonOAuth(
name: string,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentProviderAuthState | undefined> {
const started = await client.startPaseoAgentOAuth(name);
const started = await client.startPaseoAgentOAuth(name, { mode: "device_code" });
if (!started.success) {
throw {
code: "OAUTH_START_FAILED",
+4 -1
View File
@@ -3927,13 +3927,16 @@ export class DaemonClient {
async startPaseoAgentOAuth(
name: string,
requestId?: string,
options?: string | { mode?: string; requestId?: string },
): Promise<PaseoAgentOAuthStartResponse["payload"]> {
const requestId = typeof options === "string" ? options : options?.requestId;
const mode = typeof options === "string" ? undefined : options?.mode;
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: {
type: "config.paseo_agent.oauth.start.request",
name,
...(mode ? { mode } : {}),
},
timeout: 30000,
});
@@ -127,6 +127,7 @@ describe("Paseo Agent config RPC schemas", () => {
type: "config.paseo_agent.oauth.start.request",
requestId: "req-oauth-start",
name: "subscription",
mode: "device_code",
});
const response = SessionOutboundMessageSchema.parse({
type: "config.paseo_agent.oauth.start.response",
@@ -147,6 +148,7 @@ describe("Paseo Agent config RPC schemas", () => {
});
expect(request.name).toBe("subscription");
expect(request.mode).toBe("device_code");
expect(response.payload.authorization?.kind).toBe("device_code");
expect(response.payload.authorization?.futureField).toBe("kept");
});
+1
View File
@@ -2042,6 +2042,7 @@ export const PaseoAgentOAuthStartRequestSchema = z.object({
type: z.literal("config.paseo_agent.oauth.start.request"),
requestId: z.string(),
name: z.string().trim().min(1),
mode: z.string().min(1).optional(),
});
export const PaseoAgentOAuthCompleteRequestSchema = z.object({
@@ -22,7 +22,7 @@ import type { PaseoAgentOAuthCredential } from "@getpaseo/protocol/messages";
// Pi's OAuth registry; Paseo does not reimplement OAuth protocols.
export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo;
type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
export type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
type OAuthLoginPreference = "browser" | "device";
export interface OAuthCredentialBinding {
@@ -133,6 +133,26 @@ export async function loginAndStoreOAuth(options: {
signal?: AbortSignal;
login?: OAuthLogin;
}): Promise<{ path: string }> {
const credential = await loginOAuthDevice({
flow: options.flow,
onDeviceCode: options.onDeviceCode,
signal: options.signal,
login: options.login,
});
return storeOAuthCredential({
providerInstance: options.providerInstance,
credential,
binding: { flow: options.flow, baseUrl: options.baseUrl },
env: options.env,
});
}
export async function loginOAuthDevice(options: {
flow: string;
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
signal?: AbortSignal;
login?: OAuthLogin;
}): Promise<PaseoAgentOAuthCredential> {
const login = resolveOAuthLogin(options.flow, options.login);
const credentials = await login({
onAuth: () => {},
@@ -143,12 +163,7 @@ export async function loginAndStoreOAuth(options: {
onSelect: (prompt) => selectOAuthOption(prompt, "device"),
signal: options.signal,
});
return storeOAuthCredential({
providerInstance: options.providerInstance,
credential: { type: "oauth", ...credentials },
binding: { flow: options.flow, baseUrl: options.baseUrl },
env: options.env,
});
return { type: "oauth", ...credentials };
}
export async function loginOAuthBrowser(options: {
@@ -156,6 +171,7 @@ export async function loginOAuthBrowser(options: {
onAuthUrl: (url: string, instructions?: string) => void;
promptForCode?: (message: string) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
login?: OAuthLogin;
}): Promise<PaseoAgentOAuthCredential> {
const login = resolveOAuthLogin(options.flow, options.login);
@@ -170,6 +186,7 @@ export async function loginOAuthBrowser(options: {
return options.promptForCode(prompt.message);
},
onSelect: (prompt) => selectOAuthOption(prompt, "browser"),
signal: options.signal,
});
return { type: "oauth", ...credentials };
}
+199
View File
@@ -20,6 +20,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
import type { OAuthLogin } from "./agent/providers/paseo-agent/oauth-store.js";
import {
asSessionInternals as asSessionInternalsHelper,
asAgentManager,
@@ -218,6 +219,7 @@ interface SessionForTestOptions {
serverId?: SessionOptions["serverId"];
daemonVersion?: SessionOptions["daemonVersion"];
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
paseoAgentOAuthLogin?: OAuthLogin;
downloadTokenStore?: SessionOptions["downloadTokenStore"];
messages?: unknown[];
binaryMessages?: Uint8Array[];
@@ -304,6 +306,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
serverId: options.serverId,
daemonVersion: options.daemonVersion,
daemonRuntimeConfig: options.daemonRuntimeConfig,
paseoAgentOAuthLogin: options.paseoAgentOAuthLogin,
});
}
@@ -1234,6 +1237,202 @@ describe("Paseo Agent config catalog RPCs", () => {
}
});
test("oauth.start defaults to browser auth-url and complete stores the callback credential", async () => {
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
try {
writeFileSync(
join(home, "config.json"),
JSON.stringify({
agents: {
paseo: {
providers: {
subscription: {
type: "chatgpt",
},
},
},
},
}),
);
const callbackCredential = deferred<Awaited<ReturnType<OAuthLogin>>>();
const login = vi.fn<OAuthLogin>(async (callbacks) => {
callbacks.onAuth({
url: "https://auth.example.test/oauth/authorize?client_id=test-client",
instructions: "Open this URL.",
});
return callbackCredential.promise;
});
const { manager: providerSnapshotManager, refreshSettingsSnapshot } =
createProviderSnapshotManagerStub();
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
paseoHome: home,
providerSnapshotManager,
agentManager: { updateProviderRegistry: vi.fn() },
paseoAgentOAuthLogin: login,
});
await session.handleMessage({
type: "config.paseo_agent.oauth.start.request",
requestId: "oauth-start-browser",
name: "subscription",
});
const startResponse = messages.find(
(
message,
): message is Extract<
SessionOutboundMessage,
{ type: "config.paseo_agent.oauth.start.response" }
> => message.type === "config.paseo_agent.oauth.start.response",
);
expect(startResponse?.payload).toEqual({
requestId: "oauth-start-browser",
success: true,
name: "subscription",
authorization: {
kind: "auth_url",
url: "https://auth.example.test/oauth/authorize?client_id=test-client",
instructions: "Open this URL.",
},
error: null,
});
callbackCredential.resolve({
access: "browser-access-token",
refresh: "browser-refresh-token",
expires: 456,
});
await session.handleMessage({
type: "config.paseo_agent.oauth.complete.request",
requestId: "oauth-complete-browser",
name: "subscription",
});
const completeResponse = messages.find(
(
message,
): message is Extract<
SessionOutboundMessage,
{ type: "config.paseo_agent.oauth.complete.response" }
> => message.type === "config.paseo_agent.oauth.complete.response",
);
expect(completeResponse?.payload).toEqual({
requestId: "oauth-complete-browser",
success: true,
name: "subscription",
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
});
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({ providers: ["paseo"] });
const stored = JSON.parse(readFileSync(join(home, "paseo-agent", "auth.json"), "utf8"));
expect(stored.subscription).toMatchObject({
type: "oauth",
access: "browser-access-token",
refresh: "browser-refresh-token",
binding: {
flow: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
},
});
expect(login).toHaveBeenCalledTimes(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("oauth.start mode=device_code keeps the device-code authorization path", async () => {
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
try {
writeFileSync(
join(home, "config.json"),
JSON.stringify({
agents: {
paseo: {
providers: {
subscription: {
type: "chatgpt",
},
},
},
},
}),
);
const login = vi.fn<OAuthLogin>(async (callbacks) => {
callbacks.onDeviceCode({
userCode: "ABCD-EFGH",
verificationUri: "https://auth.example.test/device",
intervalSeconds: 5,
expiresInSeconds: 900,
});
return { access: "device-access-token", refresh: "device-refresh-token", expires: 789 };
});
const { manager: providerSnapshotManager } = createProviderSnapshotManagerStub();
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
paseoHome: home,
providerSnapshotManager,
agentManager: { updateProviderRegistry: vi.fn() },
paseoAgentOAuthLogin: login,
});
await session.handleMessage({
type: "config.paseo_agent.oauth.start.request",
requestId: "oauth-start-device-code",
name: "subscription",
mode: "device_code",
});
const startResponse = messages.find(
(
message,
): message is Extract<
SessionOutboundMessage,
{ type: "config.paseo_agent.oauth.start.response" }
> => message.type === "config.paseo_agent.oauth.start.response",
);
expect(startResponse?.payload).toEqual({
requestId: "oauth-start-device-code",
success: true,
name: "subscription",
authorization: {
kind: "device_code",
userCode: "ABCD-EFGH",
verificationUri: "https://auth.example.test/device",
intervalSeconds: 5,
expiresInSeconds: 900,
},
error: null,
});
await session.handleMessage({
type: "config.paseo_agent.oauth.complete.request",
requestId: "oauth-complete-device-code",
name: "subscription",
});
const completeResponse = messages.findLast(
(
message,
): message is Extract<
SessionOutboundMessage,
{ type: "config.paseo_agent.oauth.complete.response" }
> => message.type === "config.paseo_agent.oauth.complete.response",
);
expect(completeResponse?.payload).toMatchObject({
requestId: "oauth-complete-device-code",
success: true,
name: "subscription",
auth: { kind: "oauth", configured: true, source: "stored" },
error: null,
});
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("oauth.store_credential persists a bound credential without echoing tokens", async () => {
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
try {
+64 -44
View File
@@ -4,14 +4,7 @@ import { stat } from "node:fs/promises";
import { basename, normalize, resolve, sep } from "path";
import { homedir } from "node:os";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthProviderInterface,
OAuthSelectPrompt,
} from "@earendil-works/pi-ai";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { OAuthDeviceCodeInfo } from "@earendil-works/pi-ai";
import {
serializeAgentStreamEvent,
type AgentSnapshotPayload,
@@ -78,7 +71,12 @@ 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 { OAuthCredentialBinding } from "./agent/providers/paseo-agent/oauth-store.js";
import {
loginOAuthBrowser,
loginOAuthDevice,
type OAuthCredentialBinding,
type OAuthLogin,
} from "./agent/providers/paseo-agent/oauth-store.js";
import type {
AgentManagerEvent,
AgentTimelineCursor,
@@ -388,6 +386,9 @@ interface WorkspaceUpdatesSubscriptionState {
}
const PASEO_AGENT_OAUTH_PENDING_TTL_MS = 15 * 60 * 1000;
const DEFAULT_PASEO_AGENT_OAUTH_MODE = "browser";
type PaseoAgentOAuthMode = "browser" | "device_code";
interface PaseoAgentOAuthStartAuthorization {
[key: string]: unknown;
@@ -415,6 +416,13 @@ interface PendingPaseoAgentOAuthFlow {
credentialPromise: Promise<PaseoAgentOAuthCredential>;
}
interface CreatePendingPaseoAgentOAuthFlowOptions {
name: string;
binding: OAuthCredentialBinding;
mode: PaseoAgentOAuthMode;
login?: OAuthLogin;
}
function deferredValue<T>(): DeferredValue<T> {
let resolveValue!: (value: T | PromiseLike<T>) => void;
let rejectValue!: (error: unknown) => void;
@@ -425,14 +433,6 @@ function deferredValue<T>(): DeferredValue<T> {
return { promise, resolve: resolveValue, reject: rejectValue };
}
function requireOAuthProvider(flow: string): OAuthProviderInterface {
const provider = getOAuthProvider(flow);
if (!provider) {
throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`);
}
return provider;
}
function deviceCodeAuthorization(info: OAuthDeviceCodeInfo): PaseoAgentOAuthStartAuthorization {
return {
kind: "device_code",
@@ -443,24 +443,28 @@ function deviceCodeAuthorization(info: OAuthDeviceCodeInfo): PaseoAgentOAuthStar
};
}
function authUrlAuthorization(info: OAuthAuthInfo): PaseoAgentOAuthStartAuthorization {
function authUrlAuthorization(
url: string,
instructions?: string,
): PaseoAgentOAuthStartAuthorization {
return {
kind: "auth_url",
url: info.url,
...(info.instructions ? { instructions: info.instructions } : {}),
url,
...(instructions ? { instructions } : {}),
};
}
function selectOAuthOption(prompt: OAuthSelectPrompt): Promise<string | undefined> {
const preferred = prompt.options.find((option) => option.label.toLowerCase().includes("device"));
return Promise.resolve((preferred ?? prompt.options[0])?.id);
function resolvePaseoAgentOAuthMode(mode: string | undefined): PaseoAgentOAuthMode {
const resolved = mode ?? DEFAULT_PASEO_AGENT_OAUTH_MODE;
if (resolved === "browser" || resolved === "device_code") {
return resolved;
}
throw new Error(`Unsupported Paseo Agent OAuth mode "${resolved}".`);
}
function createPendingPaseoAgentOAuthFlow(
name: string,
binding: OAuthCredentialBinding,
options: CreatePendingPaseoAgentOAuthFlowOptions,
): PendingPaseoAgentOAuthFlow {
const provider = requireOAuthProvider(binding.flow);
const abortController = new AbortController();
const authorization = deferredValue<PaseoAgentOAuthStartAuthorization | null>();
let authorizationSettled = false;
@@ -481,29 +485,36 @@ function createPendingPaseoAgentOAuthFlow(
authorization.reject(error);
}
const credentialPromise = provider
.login({
onAuth: (info) => settleAuthorization(authUrlAuthorization(info)),
onDeviceCode: (info) => settleAuthorization(deviceCodeAuthorization(info)),
onPrompt: async () => {
throw new Error("OAuth login requested manual input, but no prompt handler is available.");
},
onSelect: selectOAuthOption,
signal: abortController.signal,
})
.then((credentials: OAuthCredentials): PaseoAgentOAuthCredential => {
const credentialPromise =
options.mode === "browser"
? loginOAuthBrowser({
flow: options.binding.flow,
onAuthUrl: (url, instructions) =>
settleAuthorization(authUrlAuthorization(url, instructions)),
login: options.login,
signal: abortController.signal,
})
: loginOAuthDevice({
flow: options.binding.flow,
onDeviceCode: (info) => settleAuthorization(deviceCodeAuthorization(info)),
login: options.login,
signal: abortController.signal,
});
const storedCredentialPromise = credentialPromise.then(
(credential): PaseoAgentOAuthCredential => {
settleAuthorization(null);
return { type: "oauth", ...credentials };
});
void credentialPromise.catch((error) => rejectAuthorization(error));
return credential;
},
);
void storedCredentialPromise.catch((error) => rejectAuthorization(error));
return {
name,
binding,
name: options.name,
binding: options.binding,
abortController,
expiresAt: Date.now() + PASEO_AGENT_OAUTH_PENDING_TTL_MS,
authorizationPromise: authorization.promise,
credentialPromise,
credentialPromise: storedCredentialPromise,
};
}
@@ -601,6 +612,7 @@ export interface SessionOptions {
daemonVersion?: string;
daemonRuntimeConfig?: DaemonRuntimeConfig;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
paseoAgentOAuthLogin?: OAuthLogin;
}
export type SessionLifecycleIntent =
@@ -701,6 +713,7 @@ export class Session {
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private paseoAgentConfigService: PaseoAgentConfigService | null = null;
private readonly paseoAgentOAuthLogin: OAuthLogin | undefined;
private readonly pendingPaseoAgentOAuthFlows = new Map<string, PendingPaseoAgentOAuthFlow>();
private readonly serviceProxy: ServiceProxySubsystem | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
@@ -774,6 +787,7 @@ export class Session {
daemonVersion,
daemonRuntimeConfig,
getWebSocketRuntimeMetrics,
paseoAgentOAuthLogin,
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
@@ -980,6 +994,7 @@ export class Session {
logger: this.sessionLogger,
});
this.providerSnapshotManager = providerSnapshotManager;
this.paseoAgentOAuthLogin = paseoAgentOAuthLogin;
this.serviceProxy = serviceProxy ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
@@ -1984,7 +1999,12 @@ export class Session {
try {
this.deleteExpiredPaseoAgentOAuthFlows();
const binding = this.createPaseoAgentConfigService().getOAuthCredentialBinding(msg.name);
const pending = createPendingPaseoAgentOAuthFlow(msg.name, binding);
const pending = createPendingPaseoAgentOAuthFlow({
name: msg.name,
binding,
mode: resolvePaseoAgentOAuthMode(msg.mode),
login: this.paseoAgentOAuthLogin,
});
this.replacePendingPaseoAgentOAuthFlow(pending);
const authorization = await pending.authorizationPromise;
this.emit({