mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
feat: wire paseo agent provider catalog
This commit is contained in:
@@ -12,7 +12,7 @@ type PaseoAgentDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "removePaseoAgentProvider"
|
||||
| "setPaseoAgentProvider"
|
||||
| "storePaseoAgentChatGptCredential"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
>;
|
||||
|
||||
interface OpenRouterProviderInput {
|
||||
@@ -82,8 +82,8 @@ export async function seedChatGptProvider(providerName: string): Promise<void> {
|
||||
models: [{ id: "gpt-5.3-codex", reasoning: true }],
|
||||
},
|
||||
});
|
||||
await client.storePaseoAgentChatGptCredential({
|
||||
providerName,
|
||||
await client.storePaseoAgentOAuthCredential({
|
||||
name: providerName,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "fake-access-token",
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messag
|
||||
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
export function paseoAgentAuthLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string {
|
||||
if (!auth) {
|
||||
return "Auth state unavailable";
|
||||
}
|
||||
if (auth.kind === "oauth") {
|
||||
return auth.configured ? "ChatGPT login stored" : "Login required";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLoginCommand } from "./index.js";
|
||||
interface RecordedLogin {
|
||||
mode: "browser" | "device";
|
||||
providerInstance?: string;
|
||||
baseUrl?: string;
|
||||
envHome?: string | undefined;
|
||||
}
|
||||
|
||||
@@ -60,12 +61,12 @@ describe("paseo login command", () => {
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentConfig: true },
|
||||
}),
|
||||
storePaseoAgentChatGptCredential: async (input) => {
|
||||
storePaseoAgentOAuthCredential: async (input) => {
|
||||
stored.push(input);
|
||||
return {
|
||||
requestId: "request-1",
|
||||
success: true,
|
||||
providerName: input.providerName,
|
||||
name: input.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
@@ -83,7 +84,7 @@ describe("paseo login command", () => {
|
||||
expect(recorded).toEqual([{ mode: "browser" }]);
|
||||
expect(stored).toEqual([
|
||||
{
|
||||
providerName: "chatgpt",
|
||||
name: "chatgpt",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
@@ -121,6 +122,7 @@ describe("paseo login command", () => {
|
||||
loginDeviceCode: async (options) => {
|
||||
recorded.push({
|
||||
providerInstance: options.providerInstance,
|
||||
baseUrl: options.baseUrl,
|
||||
envHome: options.env?.PASEO_HOME,
|
||||
mode: "device",
|
||||
});
|
||||
@@ -144,7 +146,12 @@ describe("paseo login command", () => {
|
||||
]);
|
||||
|
||||
expect(recorded).toEqual([
|
||||
{ providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "device" },
|
||||
{
|
||||
providerInstance: "chatgpt",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
envHome: "/tmp/paseo-home",
|
||||
mode: "device",
|
||||
},
|
||||
]);
|
||||
expect(output.join("\n")).toContain("headless device-code flow");
|
||||
expect(output.join("\n")).toContain("ABCD-EFGH");
|
||||
@@ -211,7 +218,7 @@ describe("paseo login command", () => {
|
||||
serverId: "test-daemon",
|
||||
features: {},
|
||||
}),
|
||||
storePaseoAgentChatGptCredential: async (input) => {
|
||||
storePaseoAgentOAuthCredential: async (input) => {
|
||||
stored.push(input);
|
||||
throw new Error("store RPC should not be called without the capability flag");
|
||||
},
|
||||
@@ -252,10 +259,10 @@ describe("paseo login command", () => {
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentConfig: true },
|
||||
}),
|
||||
storePaseoAgentChatGptCredential: async (input) => ({
|
||||
storePaseoAgentOAuthCredential: async (input) => ({
|
||||
requestId: "request-1",
|
||||
success: true,
|
||||
providerName: input.providerName,
|
||||
name: input.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
|
||||
const PROVIDER_INSTANCE = "chatgpt";
|
||||
const OAUTH_FLOW = "openai-codex";
|
||||
const OAUTH_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
interface LoginChatgptOptions extends CommandOptions {
|
||||
deviceCode?: boolean;
|
||||
@@ -41,7 +42,7 @@ interface LoginCommandDependencies {
|
||||
connectDaemon: (options: {
|
||||
host?: string;
|
||||
}) => Promise<
|
||||
Pick<DaemonClient, "getLastServerInfoMessage" | "storePaseoAgentChatGptCredential" | "close">
|
||||
Pick<DaemonClient, "getLastServerInfoMessage" | "storePaseoAgentOAuthCredential" | "close">
|
||||
>;
|
||||
openBrowser: (url: string) => boolean;
|
||||
promptForCode: (message: string) => Promise<string>;
|
||||
@@ -139,6 +140,7 @@ async function runChatgptLogin(
|
||||
write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n");
|
||||
const { path } = await dependencies.loginDeviceCode({
|
||||
flow: OAUTH_FLOW,
|
||||
baseUrl: OAUTH_BASE_URL,
|
||||
providerInstance: PROVIDER_INSTANCE,
|
||||
env,
|
||||
onDeviceCode: (info) => printDeviceCode(write, info),
|
||||
@@ -176,8 +178,8 @@ async function runChatgptLogin(
|
||||
onProgress: (message) => write(message),
|
||||
promptForCode: dependencies.promptForCode,
|
||||
});
|
||||
const result = await client.storePaseoAgentChatGptCredential({
|
||||
providerName: PROVIDER_INSTANCE,
|
||||
const result = await client.storePaseoAgentOAuthCredential({
|
||||
name: PROVIDER_INSTANCE,
|
||||
credential,
|
||||
});
|
||||
if (!result.success || result.error) {
|
||||
|
||||
@@ -101,7 +101,7 @@ function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRoute
|
||||
return {
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
auth: provider.auth.configured ? (provider.auth.source ?? "configured") : "not configured",
|
||||
auth: provider.auth?.configured ? (provider.auth.source ?? "configured") : "not configured",
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", "),
|
||||
};
|
||||
|
||||
@@ -97,12 +97,15 @@ import type {
|
||||
import type {
|
||||
MutableDaemonConfig,
|
||||
MutableDaemonConfigPatch,
|
||||
PaseoAgentGetCatalogResponse,
|
||||
PaseoAgentGetProvidersResponse,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentOAuthStoreCredentialResponse,
|
||||
PaseoAgentRemoveProviderResponse,
|
||||
PaseoAgentSetProviderRequest,
|
||||
PaseoAgentSetProviderResponse,
|
||||
PaseoAgentStoreChatGptCredentialResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
|
||||
@@ -3882,6 +3885,16 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPaseoAgentCatalog(requestId?: string): Promise<PaseoAgentGetCatalogResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async setPaseoAgentProvider(
|
||||
input: Omit<PaseoAgentSetProviderRequest, "type" | "requestId"> & { requestId?: string },
|
||||
): Promise<PaseoAgentSetProviderResponse["payload"]> {
|
||||
@@ -3911,16 +3924,44 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async storePaseoAgentChatGptCredential(input: {
|
||||
providerName: string;
|
||||
async startPaseoAgentOAuth(
|
||||
name: string,
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentOAuthStartResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
name,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async completePaseoAgentOAuth(
|
||||
name: string,
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentOAuthCompleteResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
name,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async storePaseoAgentOAuthCredential(input: {
|
||||
name: string;
|
||||
credential: PaseoAgentOAuthCredential;
|
||||
requestId?: string;
|
||||
}): Promise<PaseoAgentStoreChatGptCredentialResponse["payload"]> {
|
||||
}): Promise<PaseoAgentOAuthStoreCredentialResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.store_chatgpt_credential.request",
|
||||
providerName: input.providerName,
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
name: input.name,
|
||||
credential: input.credential,
|
||||
},
|
||||
timeout: 30000,
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("Paseo Agent config RPC schemas", () => {
|
||||
name: "kimi-main",
|
||||
providerType: "kimi-coding",
|
||||
models: [{ id: "kimi-k3" }],
|
||||
auth: { kind: "api_key", configured: true, source: "env" },
|
||||
auth: { kind: "future_auth_kind", configured: true, source: "future_source" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
@@ -68,11 +68,120 @@ describe("Paseo Agent config RPC schemas", () => {
|
||||
expect(JSON.stringify(parsed)).not.toContain("apiKey");
|
||||
});
|
||||
|
||||
test("parses get_catalog request and response with forward-tolerant entries", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
requestId: "req-catalog",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: "req-catalog",
|
||||
catalog: [
|
||||
{
|
||||
id: "future-provider",
|
||||
label: "Future Provider",
|
||||
iconName: "sparkles",
|
||||
docsUrl: "https://docs.example.test/provider",
|
||||
api: "future-api",
|
||||
baseUrl: "https://api.example.test",
|
||||
headers: { "User-Agent": "PaseoTest/1" },
|
||||
compat: { minHost: "0.1.104" },
|
||||
auth: { kind: "future_oauth", flow: "future-flow", extraAuthField: true },
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
label: "Future Model",
|
||||
futureModelField: "kept",
|
||||
},
|
||||
],
|
||||
futureEntryField: { keep: true },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.type).toBe("config.paseo_agent.get_catalog.request");
|
||||
expect(response.payload.catalog[0]?.auth.kind).toBe("future_oauth");
|
||||
expect(response.payload.catalog[0]?.futureEntryField).toEqual({ keep: true });
|
||||
expect(response.payload.catalog[0]?.models[0]?.futureModelField).toBe("kept");
|
||||
});
|
||||
|
||||
test("parses oauth.start request and device-code response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "req-oauth-start",
|
||||
name: "subscription",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-start",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
futureField: "kept",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.name).toBe("subscription");
|
||||
expect(response.payload.authorization?.kind).toBe("device_code");
|
||||
expect(response.payload.authorization?.futureField).toBe("kept");
|
||||
});
|
||||
|
||||
test("parses oauth.start auth-url response", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-start-url",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://auth.example.test/oauth",
|
||||
instructions: "Open this URL to continue.",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.authorization?.url).toBe("https://auth.example.test/oauth");
|
||||
});
|
||||
|
||||
test("parses oauth.complete request and response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
requestId: "req-oauth-complete",
|
||||
name: "subscription",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-complete",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.name).toBe("subscription");
|
||||
expect(response.payload.auth?.configured).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves future OAuth credential fields on inbound schema parse", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.store_chatgpt_credential.request",
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
requestId: "req-oauth",
|
||||
providerName: "chatgpt",
|
||||
name: "subscription",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
@@ -86,6 +195,23 @@ describe("Paseo Agent config RPC schemas", () => {
|
||||
expect(parsed.credential.futureField).toEqual({ keep: true });
|
||||
});
|
||||
|
||||
test("parses oauth.store_credential response without credential material", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: "req-oauth",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.name).toBe("subscription");
|
||||
expect(JSON.stringify(parsed)).not.toContain("access-token");
|
||||
expect(JSON.stringify(parsed)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("parses ChatGPT provider config separately from credential storage", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
|
||||
@@ -1960,16 +1960,46 @@ const PaseoAgentOAuthCredentialSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PaseoAgentCatalogModelSchema = 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(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PaseoAgentCatalogAuthSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentCatalogEntrySchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
iconName: z.string().min(1).optional(),
|
||||
docsUrl: z.string().optional(),
|
||||
api: z.string().min(1),
|
||||
baseUrl: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
compat: z.record(z.string(), z.unknown()).optional(),
|
||||
auth: PaseoAgentCatalogAuthSchema,
|
||||
models: z.array(PaseoAgentCatalogModelSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentProviderAuthStateSchema = z
|
||||
.object({
|
||||
kind: z.enum(["api_key", "oauth", "none"]),
|
||||
kind: z.string().min(1),
|
||||
configured: z.boolean(),
|
||||
source: z
|
||||
.enum(["literal", "env", "default_env", "command", "stored", "refresh_token"])
|
||||
.optional(),
|
||||
source: z.string().min(1).optional(),
|
||||
hint: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
.passthrough();
|
||||
|
||||
export const RedactedPaseoAgentProviderConfigSchema = z
|
||||
.object({
|
||||
@@ -1978,17 +2008,22 @@ export const RedactedPaseoAgentProviderConfigSchema = z
|
||||
baseUrl: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
models: z.array(PaseoAgentProviderModelConfigSchema),
|
||||
auth: PaseoAgentProviderAuthStateSchema,
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
available: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentGetProvidersRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_providers.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetCatalogRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_catalog.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoAgentSetProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.request"),
|
||||
requestId: z.string(),
|
||||
@@ -2003,10 +2038,22 @@ export const PaseoAgentRemoveProviderRequestSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentStoreChatGptCredentialRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.store_chatgpt_credential.request"),
|
||||
export const PaseoAgentOAuthStartRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.request"),
|
||||
requestId: z.string(),
|
||||
providerName: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthCompleteRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.complete.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStoreCredentialRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.store_credential.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
credential: PaseoAgentOAuthCredentialSchema,
|
||||
});
|
||||
|
||||
@@ -2162,9 +2209,12 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModesRequestMessageSchema,
|
||||
ListProviderFeaturesRequestMessageSchema,
|
||||
PaseoAgentGetProvidersRequestSchema,
|
||||
PaseoAgentGetCatalogRequestSchema,
|
||||
PaseoAgentSetProviderRequestSchema,
|
||||
PaseoAgentRemoveProviderRequestSchema,
|
||||
PaseoAgentStoreChatGptCredentialRequestSchema,
|
||||
PaseoAgentOAuthStartRequestSchema,
|
||||
PaseoAgentOAuthCompleteRequestSchema,
|
||||
PaseoAgentOAuthStoreCredentialRequestSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
@@ -2458,6 +2508,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
agentForkContext: z.boolean().optional(),
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
|
||||
paseoAgentConfig: z.boolean().optional(),
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104
|
||||
paseoAgentCatalog: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -3974,6 +4026,15 @@ export const PaseoAgentGetProvidersResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetCatalogResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_catalog.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
catalog: z.array(PaseoAgentCatalogEntrySchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentSetProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.response"),
|
||||
payload: z.object({
|
||||
@@ -3994,13 +4055,47 @@ export const PaseoAgentRemoveProviderResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentStoreChatGptCredentialResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.store_chatgpt_credential.response"),
|
||||
const PaseoAgentOAuthStartAuthorizationSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
url: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
userCode: z.string().optional(),
|
||||
verificationUri: z.string().optional(),
|
||||
intervalSeconds: z.number().optional(),
|
||||
expiresInSeconds: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentOAuthStartResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
providerName: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema,
|
||||
name: z.string(),
|
||||
authorization: PaseoAgentOAuthStartAuthorizationSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthCompleteResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.complete.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStoreCredentialResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.store_credential.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -4401,9 +4496,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModesResponseMessageSchema,
|
||||
ListProviderFeaturesResponseMessageSchema,
|
||||
PaseoAgentGetProvidersResponseSchema,
|
||||
PaseoAgentGetCatalogResponseSchema,
|
||||
PaseoAgentSetProviderResponseSchema,
|
||||
PaseoAgentRemoveProviderResponseSchema,
|
||||
PaseoAgentStoreChatGptCredentialResponseSchema,
|
||||
PaseoAgentOAuthStartResponseSchema,
|
||||
PaseoAgentOAuthCompleteResponseSchema,
|
||||
PaseoAgentOAuthStoreCredentialResponseSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
@@ -4541,13 +4639,17 @@ export type RedactedPaseoAgentProviderConfig = z.infer<
|
||||
>;
|
||||
export type PaseoAgentProviderAuthState = z.infer<typeof PaseoAgentProviderAuthStateSchema>;
|
||||
export type PaseoAgentOAuthCredential = z.infer<typeof PaseoAgentOAuthCredentialSchema>;
|
||||
export type PaseoAgentCatalogEntry = z.infer<typeof PaseoAgentCatalogEntrySchema>;
|
||||
export type PaseoAgentGetProvidersResponse = z.infer<typeof PaseoAgentGetProvidersResponseSchema>;
|
||||
export type PaseoAgentGetCatalogResponse = z.infer<typeof PaseoAgentGetCatalogResponseSchema>;
|
||||
export type PaseoAgentSetProviderResponse = z.infer<typeof PaseoAgentSetProviderResponseSchema>;
|
||||
export type PaseoAgentRemoveProviderResponse = z.infer<
|
||||
typeof PaseoAgentRemoveProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentStoreChatGptCredentialResponse = z.infer<
|
||||
typeof PaseoAgentStoreChatGptCredentialResponseSchema
|
||||
export type PaseoAgentOAuthStartResponse = z.infer<typeof PaseoAgentOAuthStartResponseSchema>;
|
||||
export type PaseoAgentOAuthCompleteResponse = z.infer<typeof PaseoAgentOAuthCompleteResponseSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialResponse = z.infer<
|
||||
typeof PaseoAgentOAuthStoreCredentialResponseSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
|
||||
@@ -4626,10 +4728,13 @@ export type ListProviderFeaturesRequestMessage = z.infer<
|
||||
typeof ListProviderFeaturesRequestMessageSchema
|
||||
>;
|
||||
export type PaseoAgentGetProvidersRequest = z.infer<typeof PaseoAgentGetProvidersRequestSchema>;
|
||||
export type PaseoAgentGetCatalogRequest = z.infer<typeof PaseoAgentGetCatalogRequestSchema>;
|
||||
export type PaseoAgentSetProviderRequest = z.infer<typeof PaseoAgentSetProviderRequestSchema>;
|
||||
export type PaseoAgentRemoveProviderRequest = z.infer<typeof PaseoAgentRemoveProviderRequestSchema>;
|
||||
export type PaseoAgentStoreChatGptCredentialRequest = z.infer<
|
||||
typeof PaseoAgentStoreChatGptCredentialRequestSchema
|
||||
export type PaseoAgentOAuthStartRequest = z.infer<typeof PaseoAgentOAuthStartRequestSchema>;
|
||||
export type PaseoAgentOAuthCompleteRequest = z.infer<typeof PaseoAgentOAuthCompleteRequestSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialRequest = z.infer<
|
||||
typeof PaseoAgentOAuthStoreCredentialRequestSchema
|
||||
>;
|
||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||
typeof ListAvailableProvidersRequestMessageSchema
|
||||
|
||||
@@ -184,6 +184,7 @@ describe("PaseoAgentClient", () => {
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.com/backend-api" },
|
||||
env: { PASEO_HOME: paseoHome },
|
||||
});
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
@@ -206,6 +207,27 @@ describe("PaseoAgentClient", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat an endpoint-mismatched OAuth credential as available", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" },
|
||||
env: { PASEO_HOME: paseoHome },
|
||||
});
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
expect(await client.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("lists only configured models, never Pi disk/default models", async () => {
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
|
||||
const { models } = await client.fetchCatalog({
|
||||
|
||||
@@ -44,7 +44,11 @@ import {
|
||||
type PiTrackedToolCall,
|
||||
} from "./event-mapping.js";
|
||||
import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js";
|
||||
import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js";
|
||||
import {
|
||||
createBoundPaseoAgentAuthStorage,
|
||||
hasStoredOAuthCredential,
|
||||
type OAuthCredentialBinding,
|
||||
} from "./oauth-store.js";
|
||||
import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js";
|
||||
import { createToolPermissionPolicy } from "./agent-permissions.js";
|
||||
import {
|
||||
@@ -102,6 +106,21 @@ function isAbortError(error: unknown): boolean {
|
||||
return /\brequest was aborted\b|\babort(ed)?\b/i.test(errorToMessage(error));
|
||||
}
|
||||
|
||||
function oauthCredentialBindings(
|
||||
providers: Awaited<ReturnType<typeof paseoAgentModelProviders>>,
|
||||
): Record<string, OAuthCredentialBinding> {
|
||||
const bindings: Record<string, OAuthCredentialBinding> = {};
|
||||
for (const provider of providers) {
|
||||
if (provider.oauth) {
|
||||
bindings[provider.name] = {
|
||||
flow: provider.oauth.flow,
|
||||
baseUrl: provider.config.baseUrl ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
interface PaseoAgentClientOptions {
|
||||
logger: Logger;
|
||||
config: PaseoAgentConfig;
|
||||
@@ -564,7 +583,10 @@ export class PaseoAgentClient implements AgentClient {
|
||||
// the stored credential and persists refreshed tokens (rotation) back to it.
|
||||
const usesOAuth = modelProviders.some((provider) => provider.oauth);
|
||||
const authStorage = usesOAuth
|
||||
? createPaseoAgentAuthStorage(envForPaseoHome(this.paseoHome))
|
||||
? createBoundPaseoAgentAuthStorage(
|
||||
oauthCredentialBindings(modelProviders),
|
||||
envForPaseoHome(this.paseoHome),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools.
|
||||
@@ -607,8 +629,8 @@ export class PaseoAgentClient implements AgentClient {
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const env = envForPaseoHome(this.paseoHome);
|
||||
return paseoAgentHasUsableModel(this.config, env, (providerInstance) =>
|
||||
hasStoredOAuthCredential(providerInstance, env),
|
||||
return paseoAgentHasUsableModel(this.config, env, (providerInstance, binding) =>
|
||||
hasStoredOAuthCredential(providerInstance, env, binding),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface PaseoAgentCatalogModel {
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
label?: string;
|
||||
api?: string;
|
||||
@@ -8,6 +9,7 @@ export interface PaseoAgentCatalogModel {
|
||||
}
|
||||
|
||||
export interface PaseoAgentApiKeyAuth {
|
||||
[key: string]: unknown;
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
@@ -16,6 +18,7 @@ export interface PaseoAgentApiKeyAuth {
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuth {
|
||||
[key: string]: unknown;
|
||||
kind: "oauth";
|
||||
flow: string;
|
||||
}
|
||||
@@ -23,6 +26,7 @@ export interface PaseoAgentOAuthAuth {
|
||||
export type PaseoAgentCatalogAuth = PaseoAgentApiKeyAuth | PaseoAgentOAuthAuth;
|
||||
|
||||
export interface PaseoAgentCatalogEntry {
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
label: string;
|
||||
iconName?: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js";
|
||||
import { PaseoAgentConfigService } from "./config-service.js";
|
||||
import { PaseoAgentConfigSchema } from "./config.js";
|
||||
import { paseoAgentAuthStoragePath } from "./oauth-store.js";
|
||||
import { paseoAgentAuthStoragePath, storeOAuthCredential } from "./oauth-store.js";
|
||||
|
||||
describe("PaseoAgentConfigService", () => {
|
||||
let home: string;
|
||||
@@ -56,6 +56,48 @@ describe("PaseoAgentConfigService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("reports API-key default env auth state without resolving the key value", () => {
|
||||
const missingService = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
env: { PASEO_HOME: home },
|
||||
});
|
||||
missingService.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(missingService.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
configured: false,
|
||||
source: "default_env",
|
||||
hint: "OPENROUTER_API_KEY",
|
||||
},
|
||||
available: false,
|
||||
});
|
||||
|
||||
const presentService = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
env: { PASEO_HOME: home, OPENROUTER_API_KEY: "sk-env-secret" },
|
||||
});
|
||||
|
||||
expect(presentService.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
configured: true,
|
||||
source: "default_env",
|
||||
hint: "OPENROUTER_API_KEY",
|
||||
},
|
||||
available: true,
|
||||
});
|
||||
expect(JSON.stringify(presentService.getProviders())).not.toContain("sk-env-secret");
|
||||
});
|
||||
|
||||
test("rejects an unknown provider type with a clear error and persists nothing", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
@@ -185,6 +227,10 @@ describe("PaseoAgentConfigService", () => {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
binding: {
|
||||
flow: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
futureField: { keep: true },
|
||||
});
|
||||
expect(authPath).toBe(join(home, "paseo-agent", "auth.json"));
|
||||
@@ -221,6 +267,47 @@ describe("PaseoAgentConfigService", () => {
|
||||
expect(JSON.stringify(providers)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("reports OAuth as missing or needing attention for absent and mismatched credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: { kind: "oauth", configured: false },
|
||||
available: false,
|
||||
});
|
||||
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env: { PASEO_HOME: home },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
source: "stored",
|
||||
hint: "binding_mismatch",
|
||||
},
|
||||
available: false,
|
||||
});
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("access-token");
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("removes providers and clears a default model owned by that provider", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
|
||||
@@ -16,8 +16,16 @@ import {
|
||||
resolvePaseoAgentProviderModels,
|
||||
resolvePaseoAgentProviderSettings,
|
||||
} from "./config.js";
|
||||
import { requirePaseoAgentCatalogEntry } from "./catalog.js";
|
||||
import { hasStoredOAuthCredential, storeOAuthCredential } from "./oauth-store.js";
|
||||
import {
|
||||
PASEO_AGENT_PROVIDER_CATALOG,
|
||||
type PaseoAgentCatalogEntry,
|
||||
requirePaseoAgentCatalogEntry,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
getStoredOAuthCredentialState,
|
||||
storeOAuthCredential,
|
||||
type OAuthCredentialBinding,
|
||||
} from "./oauth-store.js";
|
||||
import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js";
|
||||
import { findEnvReferences } from "./env-references.js";
|
||||
|
||||
@@ -83,6 +91,23 @@ function authStateForApiKey(
|
||||
return { kind: "api_key", configured: true, source: "literal" };
|
||||
}
|
||||
|
||||
function copyCatalogEntry(entry: PaseoAgentCatalogEntry): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
...entry,
|
||||
...(entry.headers ? { headers: { ...entry.headers } } : {}),
|
||||
...(entry.compat ? { compat: { ...entry.compat } } : {}),
|
||||
auth: { ...entry.auth },
|
||||
models: entry.models.map((model) => ({ ...model })),
|
||||
};
|
||||
}
|
||||
|
||||
function oauthBindingForSettings(
|
||||
flow: string,
|
||||
settings: ReturnType<typeof resolvePaseoAgentProviderSettings>,
|
||||
): OAuthCredentialBinding {
|
||||
return { flow, baseUrl: settings.baseUrl };
|
||||
}
|
||||
|
||||
function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig {
|
||||
return validatePaseoAgentConfig(PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {}));
|
||||
}
|
||||
@@ -110,10 +135,23 @@ function redactedProviders(
|
||||
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 };
|
||||
const stored = getStoredOAuthCredentialState(
|
||||
name,
|
||||
env,
|
||||
oauthBindingForSettings(catalogEntry.auth.flow, settings),
|
||||
);
|
||||
if (stored.present && stored.bindingMatches) {
|
||||
auth = { kind: "oauth", configured: true, source: "stored" };
|
||||
} else if (stored.present) {
|
||||
auth = {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
source: "stored",
|
||||
hint: "binding_mismatch",
|
||||
};
|
||||
} else {
|
||||
auth = { kind: "oauth", configured: false };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auth = authStateForApiKey(entry.options.apiKey, catalogEntry.auth.envVar, env);
|
||||
@@ -158,6 +196,10 @@ export class PaseoAgentConfigService {
|
||||
this.onConfigChanged = options.onConfigChanged;
|
||||
}
|
||||
|
||||
getCatalog(): PaseoAgentCatalogEntry[] {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map(copyCatalogEntry);
|
||||
}
|
||||
|
||||
getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
return {
|
||||
@@ -198,7 +240,7 @@ export class PaseoAgentConfigService {
|
||||
return removed;
|
||||
}
|
||||
|
||||
storeOAuthCredential(providerName: string, credential: PaseoAgentOAuthCredential): void {
|
||||
getOAuthCredentialBinding(providerName: string): OAuthCredentialBinding {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
const entry = config.providers?.[providerName];
|
||||
if (!entry) {
|
||||
@@ -208,12 +250,38 @@ export class PaseoAgentConfigService {
|
||||
if (catalogEntry.auth.kind !== "oauth") {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
|
||||
}
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
return oauthBindingForSettings(catalogEntry.auth.flow, settings);
|
||||
}
|
||||
|
||||
storeOAuthCredential(
|
||||
providerName: string,
|
||||
credential: PaseoAgentOAuthCredential,
|
||||
binding: OAuthCredentialBinding | undefined = undefined,
|
||||
): PaseoAgentProviderAuthState {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
const entry = config.providers?.[providerName];
|
||||
if (!entry) {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
|
||||
}
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
if (catalogEntry.auth.kind !== "oauth") {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
|
||||
}
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
storeOAuthCredential({
|
||||
providerInstance: providerName,
|
||||
credential,
|
||||
binding: binding ?? oauthBindingForSettings(catalogEntry.auth.flow, settings),
|
||||
env: this.env,
|
||||
});
|
||||
this.onConfigChanged?.(config);
|
||||
return (
|
||||
this.requireRedactedProvider(config, providerName).auth ?? {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private requireRedactedProvider(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isRefreshTokenExpressionConfigured,
|
||||
resolveRefreshTokenExpression,
|
||||
} from "./oauth-credentials.js";
|
||||
import type { OAuthCredentialBinding } from "./oauth-store.js";
|
||||
import type { PaseoAgentModelProvider, PaseoAgentModelReference } from "./pi-services.js";
|
||||
import {
|
||||
requirePaseoAgentCatalogEntry,
|
||||
@@ -225,7 +226,8 @@ export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefini
|
||||
export function paseoAgentHasUsableModel(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
isOAuthAuthed: (providerInstance: string) => boolean = () => false,
|
||||
isOAuthAuthed: (providerInstance: string, binding: OAuthCredentialBinding) => boolean = () =>
|
||||
false,
|
||||
): boolean {
|
||||
return entries(config).some(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
@@ -239,7 +241,10 @@ export function paseoAgentHasUsableModel(
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isOAuthAuthed(name);
|
||||
return isOAuthAuthed(name, {
|
||||
flow: catalogEntry.auth.flow,
|
||||
baseUrl: resolvePaseoAgentProviderSettings(entry, catalogEntry).baseUrl,
|
||||
});
|
||||
}
|
||||
return isAuthConfigured(resolvePaseoAgentProviderSettings(entry, catalogEntry).apiKey, env);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => {
|
||||
|
||||
const { path } = await loginAndStoreOAuth({
|
||||
flow: "paseo-test-oauth",
|
||||
baseUrl: "https://api.example.test/oauth",
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
onDeviceCode: () => {},
|
||||
@@ -38,6 +39,7 @@ describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: "paseo-test-oauth", baseUrl: "https://api.example.test/oauth" },
|
||||
credential: { type: "oauth", access: "ac", refresh: "rt", expires: 123 },
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-a
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBoundPaseoAgentAuthStorage,
|
||||
getStoredOAuthCredentialState,
|
||||
hasStoredOAuthCredential,
|
||||
loginAndStoreOAuth,
|
||||
loginOAuthBrowser,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
} from "./oauth-store.js";
|
||||
|
||||
const TEST_FLOW = "paseo-test-oauth-store";
|
||||
const TEST_BASE_URL = "https://api.example.test/oauth";
|
||||
|
||||
function registerTestOAuthProvider(): void {
|
||||
const provider: OAuthProviderInterface = {
|
||||
@@ -63,6 +66,7 @@ describe("oauth-store", () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
@@ -73,20 +77,62 @@ describe("oauth-store", () => {
|
||||
});
|
||||
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
|
||||
expect(
|
||||
hasStoredOAuthCredential("chatgpt", env, { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStoredOAuthCredentialState("chatgpt", env, {
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: "https://api.example.test/changed",
|
||||
}),
|
||||
).toEqual({ present: true, bindingMatches: false });
|
||||
const stored = JSON.parse(readFileSync(path, "utf8"));
|
||||
expect(stored.chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
futureField: { keep: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides mismatched credentials from runtime auth storage without deleting them", async () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createBoundPaseoAgentAuthStorage(
|
||||
{
|
||||
chatgpt: { flow: TEST_FLOW, baseUrl: "https://api.example.test/changed" },
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(authStorage.has("chatgpt")).toBe(false);
|
||||
await expect(authStorage.getApiKey("chatgpt", { includeFallback: false })).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(readFileSync(path, "utf8")).chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the Pi registry login flow and persists a Paseo-owned credential", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const deviceCodes: unknown[] = [];
|
||||
|
||||
const { path } = await loginAndStoreOAuth({
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
onDeviceCode: (info) => deviceCodes.push(info),
|
||||
@@ -103,6 +149,7 @@ describe("oauth-store", () => {
|
||||
const login = async () => ({ refresh: "rt", access: "", expires: 0 });
|
||||
await loginAndStoreOAuth({
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
providerInstance: "work-chatgpt",
|
||||
env,
|
||||
onDeviceCode: () => {},
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
AuthStorage,
|
||||
FileAuthStorageBackend,
|
||||
type AuthCredential,
|
||||
type AuthStorageBackend,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo as PiOAuthDeviceCodeInfo,
|
||||
@@ -20,6 +25,25 @@ export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo;
|
||||
type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
|
||||
type OAuthLoginPreference = "browser" | "device";
|
||||
|
||||
export interface OAuthCredentialBinding {
|
||||
flow: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface StoredOAuthCredentialState {
|
||||
present: boolean;
|
||||
bindingMatches: boolean;
|
||||
}
|
||||
|
||||
interface BoundOAuthCredential extends PaseoAgentOAuthCredential {
|
||||
binding?: OAuthCredentialBinding;
|
||||
}
|
||||
|
||||
interface StorageLockResult<T> {
|
||||
result: T;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
/** 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");
|
||||
@@ -34,6 +58,15 @@ export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env
|
||||
return AuthStorage.create(paseoAgentAuthStoragePath(env));
|
||||
}
|
||||
|
||||
export function createBoundPaseoAgentAuthStorage(
|
||||
bindings: Record<string, OAuthCredentialBinding>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AuthStorage {
|
||||
return AuthStorage.fromStorage(
|
||||
new BindingAwareAuthStorageBackend(paseoAgentAuthStoragePath(env), bindings),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only check (no file creation) for whether a Paseo-owned OAuth credential exists
|
||||
* for a provider instance. Used for availability without constructing AuthStorage.
|
||||
@@ -41,22 +74,33 @@ export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env
|
||||
export function hasStoredOAuthCredential(
|
||||
providerInstance: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
binding?: OAuthCredentialBinding,
|
||||
): boolean {
|
||||
const state = getStoredOAuthCredentialState(providerInstance, env, binding);
|
||||
return state.present && state.bindingMatches;
|
||||
}
|
||||
|
||||
export function getStoredOAuthCredentialState(
|
||||
providerInstance: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
binding?: OAuthCredentialBinding,
|
||||
): StoredOAuthCredentialState {
|
||||
const path = paseoAgentAuthStoragePath(env);
|
||||
if (!existsSync(path)) {
|
||||
return false;
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return false;
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
const entry = (parsed as Record<string, unknown>)[providerInstance];
|
||||
return (
|
||||
typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "oauth"
|
||||
);
|
||||
if (!isOAuthCredentialRecord(entry)) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
return { present: true, bindingMatches: !binding || bindingsEqual(entry.binding, binding) };
|
||||
} catch {
|
||||
return false;
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +112,21 @@ export function hasStoredOAuthCredential(
|
||||
export function storeOAuthCredential(options: {
|
||||
providerInstance: string;
|
||||
credential: PaseoAgentOAuthCredential;
|
||||
binding: OAuthCredentialBinding;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { path: string } {
|
||||
const path = paseoAgentAuthStoragePath(options.env);
|
||||
const authStorage = AuthStorage.create(path);
|
||||
authStorage.set(options.providerInstance, options.credential);
|
||||
authStorage.set(options.providerInstance, {
|
||||
...options.credential,
|
||||
binding: { ...options.binding },
|
||||
});
|
||||
return { path };
|
||||
}
|
||||
|
||||
export async function loginAndStoreOAuth(options: {
|
||||
flow: string;
|
||||
baseUrl: string;
|
||||
providerInstance: string;
|
||||
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -97,6 +146,7 @@ export async function loginAndStoreOAuth(options: {
|
||||
return storeOAuthCredential({
|
||||
providerInstance: options.providerInstance,
|
||||
credential: { type: "oauth", ...credentials },
|
||||
binding: { flow: options.flow, baseUrl: options.baseUrl },
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
@@ -124,6 +174,127 @@ export async function loginOAuthBrowser(options: {
|
||||
return { type: "oauth", ...credentials };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOAuthCredentialRecord(value: unknown): value is BoundOAuthCredential {
|
||||
return isRecord(value) && value.type === "oauth";
|
||||
}
|
||||
|
||||
function bindingsEqual(
|
||||
actual: OAuthCredentialBinding | undefined,
|
||||
expected: OAuthCredentialBinding,
|
||||
): boolean {
|
||||
return actual?.flow === expected.flow && actual.baseUrl === expected.baseUrl;
|
||||
}
|
||||
|
||||
function credentialBinding(
|
||||
credential: AuthCredential | undefined,
|
||||
): OAuthCredentialBinding | undefined {
|
||||
if (!credential || credential.type !== "oauth") {
|
||||
return undefined;
|
||||
}
|
||||
const binding = credential.binding;
|
||||
if (!isRecord(binding)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof binding.flow === "string" && typeof binding.baseUrl === "string"
|
||||
? { flow: binding.flow, baseUrl: binding.baseUrl }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseAuthStorageData(current: string | undefined): Record<string, AuthCredential> {
|
||||
if (!current) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(current);
|
||||
return isRecord(parsed) ? (parsed as Record<string, AuthCredential>) : {};
|
||||
}
|
||||
|
||||
function serializeAuthStorageData(data: Record<string, AuthCredential>): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function filterBoundCredentials(
|
||||
data: Record<string, AuthCredential>,
|
||||
bindings: Record<string, OAuthCredentialBinding>,
|
||||
): Record<string, AuthCredential> {
|
||||
const filtered: Record<string, AuthCredential> = {};
|
||||
for (const [provider, credential] of Object.entries(data)) {
|
||||
const binding = bindings[provider];
|
||||
if (
|
||||
!binding ||
|
||||
credential.type !== "oauth" ||
|
||||
bindingsEqual(credentialBinding(credential), binding)
|
||||
) {
|
||||
filtered[provider] = credential;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function mergeBindingAwareWrite(
|
||||
original: Record<string, AuthCredential>,
|
||||
visible: Record<string, AuthCredential>,
|
||||
next: string,
|
||||
): string {
|
||||
const nextData = parseAuthStorageData(next);
|
||||
const merged: Record<string, AuthCredential> = { ...original };
|
||||
for (const provider of Object.keys(visible)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(nextData, provider)) {
|
||||
delete merged[provider];
|
||||
}
|
||||
}
|
||||
for (const [provider, credential] of Object.entries(nextData)) {
|
||||
const originalBinding = credentialBinding(original[provider]);
|
||||
if (credential.type === "oauth" && !credential.binding && originalBinding) {
|
||||
merged[provider] = { ...credential, binding: originalBinding };
|
||||
} else {
|
||||
merged[provider] = credential;
|
||||
}
|
||||
}
|
||||
return serializeAuthStorageData(merged);
|
||||
}
|
||||
|
||||
class BindingAwareAuthStorageBackend implements AuthStorageBackend {
|
||||
private readonly delegate: FileAuthStorageBackend;
|
||||
private readonly bindings: Record<string, OAuthCredentialBinding>;
|
||||
|
||||
constructor(path: string, bindings: Record<string, OAuthCredentialBinding>) {
|
||||
this.delegate = new FileAuthStorageBackend(path);
|
||||
this.bindings = bindings;
|
||||
}
|
||||
|
||||
withLock<T>(fn: (current: string | undefined) => StorageLockResult<T>): T {
|
||||
return this.delegate.withLock((current) => {
|
||||
const original = parseAuthStorageData(current);
|
||||
const visible = filterBoundCredentials(original, this.bindings);
|
||||
const result = fn(serializeAuthStorageData(visible));
|
||||
return {
|
||||
result: result.result,
|
||||
...(result.next !== undefined
|
||||
? { next: mergeBindingAwareWrite(original, visible, result.next) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
withLockAsync<T>(fn: (current: string | undefined) => Promise<StorageLockResult<T>>): Promise<T> {
|
||||
return this.delegate.withLockAsync(async (current) => {
|
||||
const original = parseAuthStorageData(current);
|
||||
const visible = filterBoundCredentials(original, this.bindings);
|
||||
const result = await fn(serializeAuthStorageData(visible));
|
||||
return {
|
||||
result: result.result,
|
||||
...(result.next !== undefined
|
||||
? { next: mergeBindingAwareWrite(original, visible, result.next) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOAuthLogin(flow: string, login: OAuthLogin | undefined): OAuthLogin {
|
||||
if (login) {
|
||||
return login;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve as resolvePath } from "path";
|
||||
import pino from "pino";
|
||||
@@ -1154,6 +1154,160 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Paseo Agent config catalog RPCs", () => {
|
||||
test("get_catalog returns static manifests without credential material", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({ messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
requestId: "catalog-1",
|
||||
});
|
||||
|
||||
const response = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.get_catalog.response" }
|
||||
> => message.type === "config.paseo_agent.get_catalog.response",
|
||||
);
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.catalog.map((entry) => entry.id)).toEqual([
|
||||
"openrouter",
|
||||
"chatgpt",
|
||||
"kimi",
|
||||
"opencode-go",
|
||||
]);
|
||||
expect(JSON.stringify(response?.payload.catalog)).not.toContain("apiKey");
|
||||
expect(JSON.stringify(response?.payload.catalog)).not.toContain("access-token");
|
||||
expect(JSON.stringify(response?.payload.catalog)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("oauth.start fails clearly for an API-key provider instance", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({ messages, paseoHome: home });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "oauth-start-api-key",
|
||||
name: "openrouter-main",
|
||||
});
|
||||
|
||||
const response = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.start.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.start.response",
|
||||
);
|
||||
expect(response?.payload).toMatchObject({
|
||||
requestId: "oauth-start-api-key",
|
||||
success: false,
|
||||
name: "openrouter-main",
|
||||
authorization: null,
|
||||
});
|
||||
expect(response?.payload.error).toContain("does not use OAuth");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("oauth.store_credential persists a bound credential without echoing tokens", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-agent-session-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
providers: {
|
||||
subscription: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { manager: providerSnapshotManager, refreshSettingsSnapshot } =
|
||||
createProviderSnapshotManagerStub();
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
paseoHome: home,
|
||||
providerSnapshotManager,
|
||||
agentManager: { updateProviderRegistry: vi.fn() },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
requestId: "oauth-store-1",
|
||||
name: "subscription",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
const response = messages.find(
|
||||
(
|
||||
message,
|
||||
): message is Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.store_credential.response" }
|
||||
> => message.type === "config.paseo_agent.oauth.store_credential.response",
|
||||
);
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: "oauth-store-1",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
});
|
||||
expect(JSON.stringify(response)).not.toContain("access-token");
|
||||
expect(JSON.stringify(response)).not.toContain("refresh-token");
|
||||
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({ providers: ["paseo"] });
|
||||
|
||||
const stored = JSON.parse(readFileSync(join(home, "paseo-agent", "auth.json"), "utf8"));
|
||||
expect(stored.subscription).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
binding: {
|
||||
flow: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session provider refresh cwd routing", () => {
|
||||
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { stat } from "node:fs/promises";
|
||||
import { basename, normalize, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type {
|
||||
OAuthAuthInfo,
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo,
|
||||
OAuthProviderInterface,
|
||||
OAuthSelectPrompt,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import {
|
||||
serializeAgentStreamEvent,
|
||||
type AgentSnapshotPayload,
|
||||
@@ -18,6 +26,7 @@ import {
|
||||
type ProjectPlacementPayload,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
type PaseoAgentOAuthCredential,
|
||||
} from "./messages.js";
|
||||
import type {
|
||||
TerminalManager,
|
||||
@@ -69,6 +78,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 { OAuthCredentialBinding } from "./agent/providers/paseo-agent/oauth-store.js";
|
||||
import type {
|
||||
AgentManagerEvent,
|
||||
AgentTimelineCursor,
|
||||
@@ -377,6 +387,126 @@ interface WorkspaceUpdatesSubscriptionState {
|
||||
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
||||
}
|
||||
|
||||
const PASEO_AGENT_OAUTH_PENDING_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
interface PaseoAgentOAuthStartAuthorization {
|
||||
[key: string]: unknown;
|
||||
kind: string;
|
||||
url?: string;
|
||||
instructions?: string;
|
||||
userCode?: string;
|
||||
verificationUri?: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
interface DeferredValue<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface PendingPaseoAgentOAuthFlow {
|
||||
name: string;
|
||||
binding: OAuthCredentialBinding;
|
||||
abortController: AbortController;
|
||||
expiresAt: number;
|
||||
authorizationPromise: Promise<PaseoAgentOAuthStartAuthorization | null>;
|
||||
credentialPromise: Promise<PaseoAgentOAuthCredential>;
|
||||
}
|
||||
|
||||
function deferredValue<T>(): DeferredValue<T> {
|
||||
let resolveValue!: (value: T | PromiseLike<T>) => void;
|
||||
let rejectValue!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolveValue = innerResolve;
|
||||
rejectValue = innerReject;
|
||||
});
|
||||
return { promise, resolve: resolveValue, reject: rejectValue };
|
||||
}
|
||||
|
||||
function requireOAuthProvider(flow: string): OAuthProviderInterface {
|
||||
const provider = getOAuthProvider(flow);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function deviceCodeAuthorization(info: OAuthDeviceCodeInfo): PaseoAgentOAuthStartAuthorization {
|
||||
return {
|
||||
kind: "device_code",
|
||||
userCode: info.userCode,
|
||||
verificationUri: info.verificationUri,
|
||||
...(info.intervalSeconds !== undefined ? { intervalSeconds: info.intervalSeconds } : {}),
|
||||
...(info.expiresInSeconds !== undefined ? { expiresInSeconds: info.expiresInSeconds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function authUrlAuthorization(info: OAuthAuthInfo): PaseoAgentOAuthStartAuthorization {
|
||||
return {
|
||||
kind: "auth_url",
|
||||
url: info.url,
|
||||
...(info.instructions ? { instructions: info.instructions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function selectOAuthOption(prompt: OAuthSelectPrompt): Promise<string | undefined> {
|
||||
const preferred = prompt.options.find((option) => option.label.toLowerCase().includes("device"));
|
||||
return Promise.resolve((preferred ?? prompt.options[0])?.id);
|
||||
}
|
||||
|
||||
function createPendingPaseoAgentOAuthFlow(
|
||||
name: string,
|
||||
binding: OAuthCredentialBinding,
|
||||
): PendingPaseoAgentOAuthFlow {
|
||||
const provider = requireOAuthProvider(binding.flow);
|
||||
const abortController = new AbortController();
|
||||
const authorization = deferredValue<PaseoAgentOAuthStartAuthorization | null>();
|
||||
let authorizationSettled = false;
|
||||
|
||||
function settleAuthorization(value: PaseoAgentOAuthStartAuthorization | null): void {
|
||||
if (authorizationSettled) {
|
||||
return;
|
||||
}
|
||||
authorizationSettled = true;
|
||||
authorization.resolve(value);
|
||||
}
|
||||
|
||||
function rejectAuthorization(error: unknown): void {
|
||||
if (authorizationSettled) {
|
||||
return;
|
||||
}
|
||||
authorizationSettled = true;
|
||||
authorization.reject(error);
|
||||
}
|
||||
|
||||
const credentialPromise = provider
|
||||
.login({
|
||||
onAuth: (info) => settleAuthorization(authUrlAuthorization(info)),
|
||||
onDeviceCode: (info) => settleAuthorization(deviceCodeAuthorization(info)),
|
||||
onPrompt: async () => {
|
||||
throw new Error("OAuth login requested manual input, but no prompt handler is available.");
|
||||
},
|
||||
onSelect: selectOAuthOption,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((credentials: OAuthCredentials): PaseoAgentOAuthCredential => {
|
||||
settleAuthorization(null);
|
||||
return { type: "oauth", ...credentials };
|
||||
});
|
||||
void credentialPromise.catch((error) => rejectAuthorization(error));
|
||||
|
||||
return {
|
||||
name,
|
||||
binding,
|
||||
abortController,
|
||||
expiresAt: Date.now() + PASEO_AGENT_OAUTH_PENDING_TTL_MS,
|
||||
authorizationPromise: authorization.promise,
|
||||
credentialPromise,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
@@ -571,6 +701,7 @@ export class Session {
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private paseoAgentConfigService: PaseoAgentConfigService | null = null;
|
||||
private readonly pendingPaseoAgentOAuthFlows = new Map<string, PendingPaseoAgentOAuthFlow>();
|
||||
private readonly serviceProxy: ServiceProxySubsystem | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
@@ -1656,12 +1787,18 @@ export class Session {
|
||||
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
||||
case "config.paseo_agent.get_providers.request":
|
||||
return this.handlePaseoAgentGetProvidersRequest(msg);
|
||||
case "config.paseo_agent.get_catalog.request":
|
||||
return this.handlePaseoAgentGetCatalogRequest(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.store_chatgpt_credential.request":
|
||||
return this.handlePaseoAgentStoreChatGptCredentialRequest(msg);
|
||||
case "config.paseo_agent.oauth.start.request":
|
||||
return this.handlePaseoAgentOAuthStartRequest(msg);
|
||||
case "config.paseo_agent.oauth.complete.request":
|
||||
return this.handlePaseoAgentOAuthCompleteRequest(msg);
|
||||
case "config.paseo_agent.oauth.store_credential.request":
|
||||
return this.handlePaseoAgentOAuthStoreCredentialRequest(msg);
|
||||
case "list_available_providers_request":
|
||||
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
||||
case "get_providers_snapshot_request":
|
||||
@@ -1693,6 +1830,58 @@ export class Session {
|
||||
await this.providerSnapshotManager.refreshSettingsSnapshot({ providers: ["paseo"] });
|
||||
}
|
||||
|
||||
private deleteExpiredPaseoAgentOAuthFlows(now = Date.now()): void {
|
||||
for (const [name, pending] of this.pendingPaseoAgentOAuthFlows) {
|
||||
if (pending.expiresAt > now) {
|
||||
continue;
|
||||
}
|
||||
pending.abortController.abort();
|
||||
this.pendingPaseoAgentOAuthFlows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
private replacePendingPaseoAgentOAuthFlow(pending: PendingPaseoAgentOAuthFlow): void {
|
||||
const previous = this.pendingPaseoAgentOAuthFlows.get(pending.name);
|
||||
previous?.abortController.abort();
|
||||
this.pendingPaseoAgentOAuthFlows.set(pending.name, pending);
|
||||
}
|
||||
|
||||
private takePendingPaseoAgentOAuthFlow(name: string): PendingPaseoAgentOAuthFlow {
|
||||
this.deleteExpiredPaseoAgentOAuthFlows();
|
||||
const pending = this.pendingPaseoAgentOAuthFlows.get(name);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending OAuth flow for Paseo Agent provider '${name}'.`);
|
||||
}
|
||||
this.pendingPaseoAgentOAuthFlows.delete(name);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async handlePaseoAgentGetCatalogRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.get_catalog.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const catalog = this.createPaseoAgentConfigService().getCatalog();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
catalog,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to read Paseo Agent catalog");
|
||||
this.emit({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
catalog: [],
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentGetProvidersRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.get_providers.request" }>,
|
||||
): Promise<void> {
|
||||
@@ -1789,36 +1978,117 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentStoreChatGptCredentialRequest(
|
||||
msg: Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "config.paseo_agent.store_chatgpt_credential.request" }
|
||||
>,
|
||||
private async handlePaseoAgentOAuthStartRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.oauth.start.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.createPaseoAgentConfigService().storeOAuthCredential(msg.providerName, msg.credential);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.deleteExpiredPaseoAgentOAuthFlows();
|
||||
const binding = this.createPaseoAgentConfigService().getOAuthCredentialBinding(msg.name);
|
||||
const pending = createPendingPaseoAgentOAuthFlow(msg.name, binding);
|
||||
this.replacePendingPaseoAgentOAuthFlow(pending);
|
||||
const authorization = await pending.authorizationPromise;
|
||||
this.emit({
|
||||
type: "config.paseo_agent.store_chatgpt_credential.response",
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
providerName: msg.providerName,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
name: msg.name,
|
||||
authorization,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.pendingPaseoAgentOAuthFlows.delete(msg.name);
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to start Paseo Agent OAuth flow",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
name: msg.name,
|
||||
authorization: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentOAuthCompleteRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "config.paseo_agent.oauth.complete.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const pending = this.takePendingPaseoAgentOAuthFlow(msg.name);
|
||||
const credential = await pending.credentialPromise;
|
||||
const auth = this.createPaseoAgentConfigService().storeOAuthCredential(
|
||||
msg.name,
|
||||
credential,
|
||||
pending.binding,
|
||||
);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
name: msg.name,
|
||||
auth,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.providerName },
|
||||
"Failed to store Paseo Agent OAuth credential",
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to complete Paseo Agent OAuth flow",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.store_chatgpt_credential.response",
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
providerName: msg.providerName,
|
||||
name: msg.name,
|
||||
auth: { kind: "oauth", configured: false },
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoAgentOAuthStoreCredentialRequest(
|
||||
msg: Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "config.paseo_agent.oauth.store_credential.request" }
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const auth = this.createPaseoAgentConfigService().storeOAuthCredential(
|
||||
msg.name,
|
||||
msg.credential,
|
||||
);
|
||||
await this.refreshPaseoAgentRuntimeSnapshot();
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: true,
|
||||
name: msg.name,
|
||||
auth,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, providerName: msg.name },
|
||||
"Failed to store Paseo Agent OAuth credential",
|
||||
);
|
||||
this.emit({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success: false,
|
||||
name: msg.name,
|
||||
auth: { kind: "oauth", configured: false },
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
@@ -5827,6 +6097,11 @@ export class Session {
|
||||
public async cleanup(): Promise<void> {
|
||||
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
|
||||
|
||||
for (const pending of this.pendingPaseoAgentOAuthFlows.values()) {
|
||||
pending.abortController.abort();
|
||||
}
|
||||
this.pendingPaseoAgentOAuthFlows.clear();
|
||||
|
||||
if (this.unsubscribeAgentEvents) {
|
||||
this.unsubscribeAgentEvents();
|
||||
this.unsubscribeAgentEvents = null;
|
||||
|
||||
@@ -1227,6 +1227,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentForkContext: true,
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
|
||||
paseoAgentConfig: true,
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104
|
||||
paseoAgentCatalog: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2335,7 +2337,7 @@ function redactPaseoAgentConfigSecrets(payload: unknown): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "config.paseo_agent.store_chatgpt_credential.request") {
|
||||
if (message.type === "config.paseo_agent.oauth.store_credential.request") {
|
||||
return {
|
||||
...record,
|
||||
message: {
|
||||
|
||||
Reference in New Issue
Block a user