mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
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:
+18
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user