mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
Add remote Paseo Agent configuration
This commit is contained in:
@@ -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