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