From 29855e07e9764081a18afaf4fb2f63478923ec1b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 11 Feb 2026 11:45:55 +0700 Subject: [PATCH] feat: add provider runtime launcher settings for agent CLIs --- .../agent/provider-launch-config.test.ts | 77 +++++++++++ .../server/agent/provider-launch-config.ts | 119 +++++++++++++++++ .../src/server/agent/provider-registry.ts | 52 ++++++-- .../server/agent/providers/claude-agent.ts | 120 ++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 68 ++++++---- .../server/agent/providers/opencode-agent.ts | 75 +++++++++-- packages/server/src/server/bootstrap.ts | 13 +- packages/server/src/server/config.ts | 1 + .../src/server/persisted-config.test.ts | 59 +++++++++ .../server/src/server/persisted-config.ts | 7 + packages/server/src/server/session.ts | 9 +- .../server/src/server/websocket-server.ts | 7 +- .../public/schemas/paseo.config.v1.json | 122 ++++++++++++++++-- .../website/src/routes/docs/configuration.tsx | 77 +++++++++++ 14 files changed, 736 insertions(+), 70 deletions(-) create mode 100644 packages/server/src/server/agent/provider-launch-config.test.ts create mode 100644 packages/server/src/server/agent/provider-launch-config.ts create mode 100644 packages/server/src/server/persisted-config.test.ts diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts new file mode 100644 index 000000000..6580aaa22 --- /dev/null +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + resolveProviderCommandPrefix, + applyProviderEnv, + type ProviderRuntimeSettings, +} from "./provider-launch-config.js"; + +describe("resolveProviderCommandPrefix", () => { + test("uses resolved default command in default mode", () => { + const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); + + const resolved = resolveProviderCommandPrefix(undefined, resolveDefault); + + expect(resolveDefault).toHaveBeenCalledTimes(1); + expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] }); + }); + + test("appends args in append mode", () => { + const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); + + const resolved = resolveProviderCommandPrefix( + { + mode: "append", + args: ["--chrome"], + }, + resolveDefault + ); + + expect(resolveDefault).toHaveBeenCalledTimes(1); + expect(resolved).toEqual({ + command: "/usr/local/bin/claude", + args: ["--chrome"], + }); + }); + + test("replaces command in replace mode without resolving default", () => { + const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); + + const resolved = resolveProviderCommandPrefix( + { + mode: "replace", + argv: ["docker", "run", "--rm", "my-wrapper"], + }, + resolveDefault + ); + + expect(resolveDefault).not.toHaveBeenCalled(); + expect(resolved).toEqual({ + command: "docker", + args: ["run", "--rm", "my-wrapper"], + }); + }); +}); + +describe("applyProviderEnv", () => { + test("merges provider env overrides", () => { + const base = { + PATH: "/usr/bin", + HOME: "/tmp", + }; + const runtime: ProviderRuntimeSettings = { + env: { + HOME: "/custom/home", + FOO: "bar", + }, + }; + + const env = applyProviderEnv(base, runtime); + + expect(env).toEqual({ + PATH: "/usr/bin", + HOME: "/custom/home", + FOO: "bar", + }); + }); +}); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts new file mode 100644 index 000000000..ff1495424 --- /dev/null +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -0,0 +1,119 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { z } from "zod"; + +import type { AgentProvider } from "./agent-sdk-types.js"; +import { AgentProviderSchema } from "./provider-manifest.js"; + +const ProviderCommandDefaultSchema = z + .object({ + mode: z.literal("default"), + }) + .strict(); + +const ProviderCommandAppendSchema = z + .object({ + mode: z.literal("append"), + args: z.array(z.string()).optional(), + }) + .strict(); + +const ProviderCommandReplaceSchema = z + .object({ + mode: z.literal("replace"), + argv: z.array(z.string().min(1)).min(1), + }) + .strict(); + +export const ProviderCommandSchema = z.discriminatedUnion("mode", [ + ProviderCommandDefaultSchema, + ProviderCommandAppendSchema, + ProviderCommandReplaceSchema, +]); + +export const ProviderRuntimeSettingsSchema = z + .object({ + command: ProviderCommandSchema.optional(), + env: z.record(z.string()).optional(), + }) + .strict(); + +export const AgentProviderRuntimeSettingsMapSchema = z.record( + AgentProviderSchema, + ProviderRuntimeSettingsSchema +); + +export type ProviderCommand = z.infer; +export type ProviderRuntimeSettings = z.infer; +export type AgentProviderRuntimeSettingsMap = Partial< + Record +>; + +export type ProviderCommandPrefix = { + command: string; + args: string[]; +}; + +export function resolveProviderCommandPrefix( + commandConfig: ProviderCommand | undefined, + resolveDefaultCommand: () => string +): ProviderCommandPrefix { + if (!commandConfig || commandConfig.mode === "default") { + return { + command: resolveDefaultCommand(), + args: [], + }; + } + + if (commandConfig.mode === "append") { + return { + command: resolveDefaultCommand(), + args: [...(commandConfig.args ?? [])], + }; + } + + return { + command: commandConfig.argv[0]!, + args: commandConfig.argv.slice(1), + }; +} + +export function applyProviderEnv( + baseEnv: Record, + runtimeSettings?: ProviderRuntimeSettings +): Record { + return { + ...baseEnv, + ...(runtimeSettings?.env ?? {}), + }; +} + +export function isCommandAvailable(command: string): boolean { + const normalized = command.trim(); + if (!normalized) { + return false; + } + + if (normalized.includes("/") || normalized.includes("\\")) { + return existsSync(normalized); + } + + try { + const output = execFileSync("which", [normalized], { encoding: "utf8" }).trim(); + return output.length > 0; + } catch { + return false; + } +} + +export function isProviderCommandAvailable( + commandConfig: ProviderCommand | undefined, + resolveDefaultCommand: () => string +): boolean { + try { + const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand); + return isCommandAvailable(prefix.command); + } catch { + return false; + } +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 37327f2a3..fc1cdb04b 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -4,6 +4,7 @@ import type { AgentProvider, ListModelsOptions, } from "./agent-sdk-types.js"; +import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js"; import type { Logger } from "pino"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; @@ -30,25 +31,45 @@ export interface ProviderDefinition extends AgentProviderDefinition { fetchModels: (options?: ListModelsOptions) => Promise; } -export function buildProviderRegistry(logger: Logger): Record { - const claudeClient = new ClaudeAgentClient({ logger }); - const codexClient = new CodexAppServerAgentClient(logger); - const opencodeClient = new OpenCodeAgentClient(logger); +type BuildProviderRegistryOptions = { + runtimeSettings?: AgentProviderRuntimeSettingsMap; +}; + +export function buildProviderRegistry( + logger: Logger, + options?: BuildProviderRegistryOptions +): Record { + const runtimeSettings = options?.runtimeSettings; + const claudeClient = new ClaudeAgentClient({ + logger, + runtimeSettings: runtimeSettings?.claude, + }); + const codexClient = new CodexAppServerAgentClient( + logger, + runtimeSettings?.codex + ); + const opencodeClient = new OpenCodeAgentClient( + logger, + runtimeSettings?.opencode + ); return { claude: { ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!, - createClient: (logger: Logger) => new ClaudeAgentClient({ logger }), + createClient: (logger: Logger) => + new ClaudeAgentClient({ logger, runtimeSettings: runtimeSettings?.claude }), fetchModels: (options) => claudeClient.listModels(options), }, codex: { ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!, - createClient: (logger: Logger) => new CodexAppServerAgentClient(logger), + createClient: (logger: Logger) => + new CodexAppServerAgentClient(logger, runtimeSettings?.codex), fetchModels: (options) => codexClient.listModels(options), }, opencode: { ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!, - createClient: (logger: Logger) => new OpenCodeAgentClient(logger), + createClient: (logger: Logger) => + new OpenCodeAgentClient(logger, runtimeSettings?.opencode), fetchModels: (options) => opencodeClient.listModels(options), }, }; @@ -57,8 +78,11 @@ export function buildProviderRegistry(logger: Logger): Record = null as any; -export function createAllClients(logger: Logger): Record { - const registry = buildProviderRegistry(logger); +export function createAllClients( + logger: Logger, + options?: BuildProviderRegistryOptions +): Record { + const registry = buildProviderRegistry(logger, options); return { claude: registry.claude.createClient(logger), codex: registry.codex.createClient(logger), @@ -66,6 +90,12 @@ export function createAllClients(logger: Logger): Record { - await OpenCodeServerManager.getInstance(logger).shutdown(); +export async function shutdownProviders( + logger: Logger, + options?: BuildProviderRegistryOptions +): Promise { + await OpenCodeServerManager.getInstance( + logger, + options?.runtimeSettings?.opencode + ).shutdown(); } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 0ddcfbf70..9300248bc 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -15,6 +15,7 @@ import { type PermissionResult, type PermissionUpdate, type Query, + type SpawnOptions, type SDKMessage, type SDKPartialAssistantMessage, type SDKResultMessage, @@ -56,6 +57,11 @@ import type { McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; +import { + applyProviderEnv, + isProviderCommandAvailable, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const fsPromises = promises; @@ -131,15 +137,56 @@ type ClaudeOptions = Options; type ClaudeAgentClientOptions = { defaults?: { agents?: Record }; logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; }; type ClaudeAgentSessionOptions = { defaults?: { agents?: Record }; claudePath: string | null; + runtimeSettings?: ProviderRuntimeSettings; handle?: AgentPersistenceHandle; logger: Logger; }; +function resolveClaudeBinary(): string { + try { + const claudePath = execSync("which claude", { encoding: "utf8" }).trim(); + if (claudePath) { + return claudePath; + } + } catch { + // fall through + } + throw new Error( + "Claude CLI not found. Install claude or configure agents.providers.claude.command.mode='replace'." + ); +} + +function resolveClaudeSpawnCommand( + spawnOptions: SpawnOptions, + runtimeSettings?: ProviderRuntimeSettings +): { command: string; args: string[] } { + const commandConfig = runtimeSettings?.command; + if (!commandConfig || commandConfig.mode === "default") { + return { + command: spawnOptions.command, + args: [...spawnOptions.args], + }; + } + + if (commandConfig.mode === "append") { + return { + command: spawnOptions.command, + args: [...(commandConfig.args ?? []), ...spawnOptions.args], + }; + } + + return { + command: commandConfig.argv[0]!, + args: [...commandConfig.argv.slice(1), ...spawnOptions.args], + }; +} + export function extractUserMessageText(content: unknown): string | null { if (typeof content === "string") { @@ -351,10 +398,12 @@ export class ClaudeAgentClient implements AgentClient { private readonly defaults?: { agents?: Record }; private readonly logger: Logger; private readonly claudePath: string | null; + private readonly runtimeSettings?: ProviderRuntimeSettings; constructor(options: ClaudeAgentClientOptions) { this.defaults = options.defaults; this.logger = options.logger.child({ module: "agent", provider: "claude" }); + this.runtimeSettings = options.runtimeSettings; try { this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null; } catch { @@ -362,11 +411,39 @@ export class ClaudeAgentClient implements AgentClient { } } + private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions { + const hasEnvOverrides = Object.keys(this.runtimeSettings?.env ?? {}).length > 0; + const commandMode = this.runtimeSettings?.command?.mode; + const needsCustomSpawn = + hasEnvOverrides || commandMode === "append" || commandMode === "replace"; + + if (!needsCustomSpawn) { + return options; + } + + return { + ...options, + spawnClaudeCodeProcess: (spawnOptions) => { + const resolved = resolveClaudeSpawnCommand( + spawnOptions, + this.runtimeSettings + ); + return spawn(resolved.command, resolved.args, { + cwd: spawnOptions.cwd, + env: applyProviderEnv(spawnOptions.env, this.runtimeSettings), + signal: spawnOptions.signal, + stdio: ["pipe", "pipe", "pipe"], + }); + }, + }; + } + async createSession(config: AgentSessionConfig): Promise { const claudeConfig = this.assertConfig(config); return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, claudePath: this.claudePath, + runtimeSettings: this.runtimeSettings, logger: this.logger, }); } @@ -385,6 +462,7 @@ export class ClaudeAgentClient implements AgentClient { return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, claudePath: this.claudePath, + runtimeSettings: this.runtimeSettings, handle, logger: this.logger, }); @@ -399,7 +477,10 @@ export class ClaudeAgentClient implements AgentClient { ...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}), }; - const claudeQuery = query({ prompt, options: claudeOptions }); + const claudeQuery = query({ + prompt, + options: this.applyRuntimeSettings(claudeOptions), + }); try { const models: ModelInfo[] = await claudeQuery.supportedModels(); return models.map((model) => ({ @@ -451,6 +532,10 @@ export class ClaudeAgentClient implements AgentClient { } async isAvailable(): Promise { + const commandConfig = this.runtimeSettings?.command; + if (commandConfig?.mode === "replace") { + return isProviderCommandAvailable(commandConfig, resolveClaudeBinary); + } return this.claudePath !== null; } @@ -469,6 +554,7 @@ class ClaudeAgentSession implements AgentSession { private readonly config: ClaudeAgentConfig; private readonly defaults?: { agents?: Record }; private readonly claudePath: string | null; + private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly logger: Logger; private query: Query | null = null; private input: Pushable | null = null; @@ -508,6 +594,7 @@ class ClaudeAgentSession implements AgentSession { this.config = config; this.defaults = options.defaults; this.claudePath = options.claudePath; + this.runtimeSettings = options.runtimeSettings; this.logger = options.logger; const handle = options.handle; @@ -971,7 +1058,34 @@ class ClaudeAgentSession implements AgentSession { if (this.claudeSessionId) { base.resume = this.claudeSessionId; } - return base; + return this.applyRuntimeSettings(base); + } + + private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions { + const hasEnvOverrides = Object.keys(this.runtimeSettings?.env ?? {}).length > 0; + const commandMode = this.runtimeSettings?.command?.mode; + const needsCustomSpawn = + hasEnvOverrides || commandMode === "append" || commandMode === "replace"; + + if (!needsCustomSpawn) { + return options; + } + + return { + ...options, + spawnClaudeCodeProcess: (spawnOptions) => { + const resolved = resolveClaudeSpawnCommand( + spawnOptions, + this.runtimeSettings + ); + return spawn(resolved.command, resolved.args, { + cwd: spawnOptions.cwd, + env: applyProviderEnv(spawnOptions.env, this.runtimeSettings), + signal: spawnOptions.signal, + stdio: ["pipe", "pipe", "pipe"], + }); + }, + }; } private normalizeMcpServers( diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index aabefe766..3d4d382f8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -39,6 +39,12 @@ import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem, } from "./codex/tool-call-mapper.js"; +import { + applyProviderEnv, + isProviderCommandAvailable, + resolveProviderCommandPrefix, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; @@ -158,6 +164,15 @@ function resolveCodexBinary(): string { ); } +function resolveCodexLaunchPrefix( + runtimeSettings?: ProviderRuntimeSettings +): { command: string; args: string[] } { + return resolveProviderCommandPrefix( + runtimeSettings?.command, + resolveCodexBinary + ); +} + function resolveCodexHomeDir(): string { return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); } @@ -1750,7 +1765,8 @@ class CodexAppServerAgentSession implements AgentSession { constructor( config: AgentSessionConfig, private readonly resumeHandle: { sessionId: string; metadata?: Record } | null, - logger: Logger + logger: Logger, + private readonly spawnAppServer: () => ChildProcessWithoutNullStreams ) { this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER }); if (config.modeId === undefined) { @@ -1773,11 +1789,7 @@ class CodexAppServerAgentSession implements AgentSession { async connect(): Promise { if (this.connected) return; - const binaryPath = resolveCodexBinary(); - const child = spawn(binaryPath, ["app-server"], { - stdio: ["pipe", "pipe", "pipe"], - env: process.env, - }); + const child = this.spawnAppServer(); this.client = new CodexAppServerClient(child, this.logger); this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.registerRequestHandlers(); @@ -2854,11 +2866,27 @@ export class CodexAppServerAgentClient implements AgentClient { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly runtimeSettings?: ProviderRuntimeSettings + ) {} + + private spawnAppServer(): ChildProcessWithoutNullStreams { + const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); + return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], { + stdio: ["pipe", "pipe", "pipe"], + env: applyProviderEnv(process.env, this.runtimeSettings), + }); + } async createSession(config: AgentSessionConfig): Promise { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; - const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger); + const session = new CodexAppServerAgentSession( + sessionConfig, + null, + this.logger, + () => this.spawnAppServer() + ); await session.connect(); return session; } @@ -2871,7 +2899,12 @@ export class CodexAppServerAgentClient implements AgentClient { provider: CODEX_PROVIDER, cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), }; - const session = new CodexAppServerAgentSession(merged, handle, this.logger); + const session = new CodexAppServerAgentSession( + merged, + handle, + this.logger, + () => this.spawnAppServer() + ); await session.connect(); return session; } @@ -2879,10 +2912,7 @@ export class CodexAppServerAgentClient implements AgentClient { async listPersistedAgents( options?: ListPersistedAgentsOptions ): Promise { - const binaryPath = resolveCodexBinary(); - const child = spawn(binaryPath, ["app-server"], { - stdio: ["pipe", "pipe", "pipe"], - }); + const child = this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); try { @@ -2957,10 +2987,7 @@ export class CodexAppServerAgentClient implements AgentClient { } async listModels(_options?: ListModelsOptions): Promise { - const binaryPath = resolveCodexBinary(); - const child = spawn(binaryPath, ["app-server"], { - stdio: ["pipe", "pipe", "pipe"], - }); + const child = this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); try { @@ -3050,11 +3077,6 @@ export class CodexAppServerAgentClient implements AgentClient { } async isAvailable(): Promise { - try { - const codexPath = execSync("which codex", { encoding: "utf8" }).trim(); - return Boolean(codexPath); - } catch { - return false; - } + return isProviderCommandAvailable(this.runtimeSettings?.command, resolveCodexBinary); } } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 02f893e43..d3fbbd580 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -26,6 +26,12 @@ import type { McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; +import { + applyProviderEnv, + isProviderCommandAvailable, + resolveProviderCommandPrefix, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { @@ -63,6 +69,20 @@ type OpenCodeMcpConfig = const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; +function resolveOpenCodeBinary(): string { + try { + const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim(); + if (opencodePath) { + return opencodePath; + } + } catch { + // fall through + } + throw new Error( + "OpenCode CLI not found. Please install opencode globally so Paseo can launch the provider." + ); +} + function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig { if (config.type === "stdio") { return { @@ -122,15 +142,31 @@ export class OpenCodeServerManager { private port: number | null = null; private startPromise: Promise<{ port: number; url: string }> | null = null; private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly runtimeSettingsKey: string; - private constructor(logger: Logger) { + private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { this.logger = logger; + this.runtimeSettings = runtimeSettings; + this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {}); } - static getInstance(logger: Logger): OpenCodeServerManager { + static getInstance( + logger: Logger, + runtimeSettings?: ProviderRuntimeSettings + ): OpenCodeServerManager { + const nextSettingsKey = JSON.stringify(runtimeSettings ?? {}); if (!OpenCodeServerManager.instance) { - OpenCodeServerManager.instance = new OpenCodeServerManager(logger); + OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings); OpenCodeServerManager.registerExitHandler(); + } else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) { + logger.warn( + { + existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey, + requestedRuntimeSettings: nextSettingsKey, + }, + "OpenCode server manager already initialized with different runtime settings" + ); } return OpenCodeServerManager.instance; } @@ -174,12 +210,20 @@ export class OpenCodeServerManager { private async startServer(): Promise<{ port: number; url: string }> { this.port = await findAvailablePort(); const url = `http://127.0.0.1:${this.port}`; + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveOpenCodeBinary + ); return new Promise((resolve, reject) => { - this.server = spawn("opencode", ["serve", "--port", String(this.port)], { + this.server = spawn( + launchPrefix.command, + [...launchPrefix.args, "serve", "--port", String(this.port)], + { stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); + env: applyProviderEnv(process.env, this.runtimeSettings), + } + ); let started = false; const timeout = setTimeout(() => { @@ -242,10 +286,15 @@ export class OpenCodeAgentClient implements AgentClient { private readonly serverManager: OpenCodeServerManager; private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; - constructor(logger: Logger) { + constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { this.logger = logger.child({ module: "agent", provider: "opencode" }); - this.serverManager = OpenCodeServerManager.getInstance(this.logger); + this.runtimeSettings = runtimeSettings; + this.serverManager = OpenCodeServerManager.getInstance( + this.logger, + runtimeSettings + ); } async createSession(config: AgentSessionConfig): Promise { @@ -381,12 +430,10 @@ export class OpenCodeAgentClient implements AgentClient { } async isAvailable(): Promise { - try { - const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim(); - return Boolean(opencodePath); - } catch { - return false; - } + return isProviderCommandAvailable( + this.runtimeSettings?.command, + resolveOpenCodeBinary + ); } private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 99a6da294..0f8722da2 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -64,6 +64,7 @@ import type { AgentClient, AgentProvider, } from "./agent/agent-sdk-types.js"; +import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { @@ -120,6 +121,7 @@ export type PaseoDaemonConfig = { voiceLlmModel?: string | null; dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; + agentProviderSettings?: AgentProviderRuntimeSettingsMap; }; export interface PaseoDaemon { @@ -274,7 +276,9 @@ export async function createPaseoDaemon( const agentStorage = new AgentStorage(config.agentStoragePath, logger); const agentManager = new AgentManager({ clients: { - ...createAllClients(logger), + ...createAllClients(logger, { + runtimeSettings: config.agentProviderSettings, + }), ...config.agentClients, }, registry: agentStorage, @@ -496,7 +500,8 @@ export async function createPaseoDaemon( finalTimeoutMs: config.dictationFinalTimeoutMs, stt: dictationSttService, localModels: localModelConfig ?? undefined, - } + }, + config.agentProviderSettings ); const start = async () => { @@ -590,7 +595,9 @@ export async function createPaseoDaemon( await agentManager.flush().catch(() => undefined); detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); - await shutdownProviders(logger); + await shutdownProviders(logger, { + runtimeSettings: config.agentProviderSettings, + }); terminalManager.killAll(); cleanupSpeechRuntime(); await relayTransport?.stop().catch(() => undefined); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index e78863184..bbd6231ed 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -125,5 +125,6 @@ export function loadConfig( voiceLlmProvider, voiceLlmProviderExplicit, voiceLlmModel, + agentProviderSettings: persisted.agents?.providers, }; } diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts new file mode 100644 index 000000000..2e9c2625b --- /dev/null +++ b/packages/server/src/server/persisted-config.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { PersistedConfigSchema } from "./persisted-config.js"; + +describe("PersistedConfigSchema agent provider runtime settings", () => { + test("accepts provider command append args and env", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + claude: { + command: { + mode: "append", + args: ["--chrome"], + }, + env: { + FOO: "bar", + }, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.claude?.command?.mode).toBe("append"); + expect(parsed.agents?.providers?.claude?.env?.FOO).toBe("bar"); + }); + + test("accepts provider command replace argv", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + codex: { + command: { + mode: "replace", + argv: ["docker", "run", "--rm", "my-codex-wrapper"], + }, + }, + }, + }, + }); + + expect(parsed.agents?.providers?.codex?.command?.mode).toBe("replace"); + }); + + test("rejects replace command without argv", () => { + const result = PersistedConfigSchema.safeParse({ + agents: { + providers: { + opencode: { + command: { + mode: "replace", + }, + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f4a6dc66f..98b20a2ac 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; +import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js"; const LogConfigSchema = z .object({ @@ -122,6 +123,12 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + agents: z + .object({ + providers: AgentProviderRuntimeSettingsMapSchema.optional(), + }) + .strict() + .optional(), features: z .object({ dictation: FeatureDictationSchema.optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 276b73dc8..0d023d447 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -48,6 +48,7 @@ import type { export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; +import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; @@ -297,6 +298,7 @@ export type SessionOptions = { defaultModelIds: LocalSpeechModelId[]; }; }; + agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; }; function convertPCMToWavBuffer( @@ -468,6 +470,7 @@ export class Session { private readonly unregisterVoiceCallerContext?: (agentId: string) => void; private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise; private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise; + private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -488,6 +491,7 @@ export class Session { voice, voiceBridge, dictation, + agentProviderRuntimeSettings, } = options; this.clientId = clientId; this.sessionId = uuidv4(); @@ -515,13 +519,16 @@ export class Session { this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent; this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent; + this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.abortController = new AbortController(); this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, sessionId: this.sessionId, }); - this.providerRegistry = buildProviderRegistry(this.sessionLogger); + this.providerRegistry = buildProviderRegistry(this.sessionLogger, { + runtimeSettings: this.agentProviderRuntimeSettings, + }); // Initialize per-session managers this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 96082c77d..940524a65 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -17,6 +17,7 @@ import type { AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed } from "./allowed-hosts.js"; import { Session } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; +import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; @@ -92,6 +93,7 @@ export class VoiceAssistantWebSocketServer { VoiceSpeakHandler >(); private readonly voiceCallerContexts = new Map(); + private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; constructor( server: HTTPServer, @@ -117,7 +119,8 @@ export class VoiceAssistantWebSocketServer { modelsDir: string; defaultModelIds: LocalSpeechModelId[]; }; - } + }, + agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -131,6 +134,7 @@ export class VoiceAssistantWebSocketServer { this.terminalManager = terminalManager ?? null; this.voice = voice ?? null; this.dictation = dictation ?? null; + this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; const pushLogger = this.logger.child({ module: "push" }); this.pushTokenStore = new PushTokenStore( @@ -257,6 +261,7 @@ export class VoiceAssistantWebSocketServer { removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent, }, dictation: this.dictation ?? undefined, + agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, }); this.sessions.set(ws, session); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index cb853f22c..0bbe1d253 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -57,6 +57,9 @@ }, "endpoint": { "type": "string" + }, + "publicEndpoint": { + "type": "string" } }, "additionalProperties": false @@ -102,6 +105,92 @@ }, "additionalProperties": false }, + "agents": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "default" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "append" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "replace" + }, + "argv": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + }, + "required": [ + "mode", + "argv" + ], + "additionalProperties": false + } + ] + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "propertyNames": { + "enum": [ + "claude", + "codex", + "opencode" + ] + } + } + }, + "additionalProperties": false + }, "features": { "type": "object", "properties": { @@ -112,10 +201,17 @@ "type": "object", "properties": { "provider": { - "type": "string", - "enum": [ - "openai", - "local" + "allOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [ + "openai", + "local" + ] + } ] }, "model": { @@ -156,11 +252,7 @@ "type": "object", "properties": { "provider": { - "type": "string", - "enum": [ - "openai", - "local" - ] + "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" }, "model": { "type": "string", @@ -173,11 +265,7 @@ "type": "object", "properties": { "provider": { - "type": "string", - "enum": [ - "openai", - "local" - ] + "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" }, "model": { "type": "string", @@ -193,6 +281,12 @@ "nova", "shimmer" ] + }, + "speakerId": { + "type": "integer" + }, + "speed": { + "type": "number" } }, "additionalProperties": false diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx index d303acfbe..d22f5b14a 100644 --- a/packages/website/src/routes/docs/configuration.tsx +++ b/packages/website/src/routes/docs/configuration.tsx @@ -78,6 +78,83 @@ function Configuration() { +
+

Agent provider runtime settings

+

+ Use agents.providers to customize how Paseo launches agent provider CLIs. + This works for claude, codex, and + opencode. +

+

+ command.mode can be default,{' '} + append, or replace. Use{' '} + env to inject provider-specific environment variables. +

+ +

Enable Claude Code Chrome MCP

+
+{`{
+  "agents": {
+    "providers": {
+      "claude": {
+        "command": {
+          "mode": "append",
+          "args": ["--chrome"]
+        }
+      }
+    }
+  }
+}`}
+        
+ +

Point Claude to Anthropic-compatible endpoints (z.ai example)

+
+{`{
+  "agents": {
+    "providers": {
+      "claude": {
+        "env": {
+          "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
+          "ANTHROPIC_AUTH_TOKEN": "auth token",
+          "ANTHROPIC_API_KEY": ""
+        }
+      }
+    }
+  }
+}`}
+        
+ +

Run Claude through Docker

+

+ Create a wrapper script that runs Claude in Docker, then tell Paseo to replace the Claude launch command + with that script. +

+
+{`{
+  "agents": {
+    "providers": {
+      "claude": {
+        "command": {
+          "mode": "replace",
+          "argv": ["/Users/you/bin/claude-docker"]
+        }
+      }
+    }
+  }
+}`}
+        
+
+{`#!/usr/bin/env bash
+set -euo pipefail
+docker run --rm -i \\
+  -v "$PWD":"$PWD" \\
+  -w "$PWD" \\
+  -v "$HOME/.claude":"$HOME/.claude" \\
+  ghcr.io/anthropics/claude-code:latest \\
+  claude "$@"`}
+        
+
+

Voice