diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index e7c18466..944d570a 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -192,6 +192,21 @@ "effort": { "type": "string", "minLength": 1 + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } } }, "required": [ diff --git a/src/cli.ts b/src/cli.ts index 27e7976c..85e3ae3d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -211,7 +211,10 @@ async function runInit({ force }: { force: boolean }): Promise { } const currentSubagents = files.config.subagents; - const availability = getLocalAgentProviderAvailabilitySnapshot(); + const availability = getLocalAgentProviderAvailabilitySnapshot( + process.env, + currentSubagents, + ); const configuredProviders = currentSubagents.providers .filter((provider) => provider.enabled) .map((provider) => provider.id); @@ -361,7 +364,7 @@ async function runDoctor(): Promise { console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); const providers = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`); console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`); @@ -486,7 +489,7 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot); const providers = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers); const output = presentAgentTargetCatalog(catalog); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 03a5cc40..d3b7b815 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,3 +1,8 @@ +import { + localAgentProviderEnvironment, + type SubagentsConfig, +} from "./local-agent-config.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -27,6 +32,7 @@ export type LocalAgentAdapter = LocalAgentDriver; export interface LocalAgentDriverOptions { env?: NodeJS.ProcessEnv; + subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; piSessionFactory?: PiSessionFactory; @@ -35,14 +41,18 @@ export interface LocalAgentDriverOptions { export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { + const env = options.env ?? process.env; + const providerEnv = (provider: LocalAgentProvider) => options.subagents + ? localAgentProviderEnvironment(options.subagents, provider, env) + : env; return [ - new CodexLocalAgentDriver(options.env), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, options.env), + new CodexLocalAgentDriver(providerEnv("codex")), + new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), new OpencodeLocalAgentDriver(options.opencodeFactory), new PiLocalAgentDriver(options.piSessionFactory), - new AcpLocalAgentDriver("cursor", options.env), - new AcpLocalAgentDriver("copilot", options.env), - new AcpLocalAgentDriver("grok", options.env), + new AcpLocalAgentDriver("cursor", providerEnv("cursor")), + new AcpLocalAgentDriver("copilot", providerEnv("copilot")), + new AcpLocalAgentDriver("grok", providerEnv("grok")), ]; } diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 7e0ebc1c..223a7c52 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; const snapshot = getLocalAgentProviderAvailabilitySnapshot({ @@ -10,3 +13,48 @@ assert.deepEqual(snapshot.find((provider) => provider.name === "codex"), { available: false, reason: "/definitely/missing/devspace-codex executable not found", }); +assert.equal( + getLocalAgentProviderAvailabilitySnapshot({ ...process.env, CODEX_COMMAND: "" }) + .find((provider) => provider.name === "codex")?.available, + false, +); + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-provider-command-")); + const executable = join(directory, "codex-wrapper"); + try { + assert.equal( + getLocalAgentProviderAvailabilitySnapshot({ + ...process.env, + CODEX_COMMAND: directory, + }).find((provider) => provider.name === "codex")?.available, + false, + ); + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); + chmodSync(executable, 0o700); + const availability = getLocalAgentProviderAvailabilitySnapshot( + { + ...process.env, + CODEX_COMMAND: "/definitely/missing/devspace-codex", + OPENAI_API_KEY: "must-not-appear", + }, + { + enabled: true, + providers: [{ + id: "codex", + enabled: true, + command: executable, + env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, + }], + }, + ).find((provider) => provider.name === "codex"); + assert.deepEqual(availability, { + name: "codex", + available: true, + note: "available", + }); + assert.doesNotMatch(JSON.stringify(availability), /configured-secret|must-not-appear/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 3a67b98f..77786f1e 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,9 +1,13 @@ -import { accessSync, constants } from "node:fs"; +import { accessSync, constants, statSync } from "node:fs"; import { delimiter, resolve } from "node:path"; import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; +import { + localAgentProviderEnvironment, + type SubagentsConfig, +} from "./local-agent-config.js"; export interface LocalAgentProviderAvailability { name: LocalAgentProvider; @@ -14,37 +18,45 @@ export interface LocalAgentProviderAvailability { export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env)); + return LOCAL_AGENT_PROVIDERS.map((provider) => ( + checkLocalAgentProviderAvailability(provider, env, config) + )); } function checkLocalAgentProviderAvailability( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): LocalAgentProviderAvailability { + const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; switch (provider) { case "codex": - return codexAvailability(env); + return codexAvailability(providerEnv); case "claude": - return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + return providerEnv.CLAUDE_COMMAND + ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); case "opencode": return packageAvailability(provider, "@opencode-ai/sdk/v2"); case "pi": return packageAvailability(provider, "@earendil-works/pi-coding-agent"); case "cursor": - return commandAvailability(provider, env.CURSOR_COMMAND ?? "cursor-agent", env); + return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); case "copilot": - return commandAvailability(provider, env.COPILOT_COMMAND ?? "copilot", env); + return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); case "grok": - return commandAvailability(provider, env.GROK_COMMAND ?? "grok", env); + return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); } } export function assertLocalAgentProviderAvailable( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env); + const availability = checkLocalAgentProviderAvailability(provider, env, config); if (availability.available) return; throw new Error( `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, @@ -91,6 +103,7 @@ function commandAvailability( } function resolveCommand(command: string, env: NodeJS.ProcessEnv): string | undefined { + if (!command) return undefined; if (command.includes("/") || command.includes("\\")) { return executableExists(command) ? command : undefined; } @@ -113,7 +126,7 @@ function executableExists(command: string): boolean { const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK; try { accessSync(command, mode); - return true; + return statSync(command).isFile(); } catch { return false; } diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index e8b3d506..14e36400 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -5,6 +5,8 @@ import { type ClaudeQueryLike, type ClaudeUserMessage, } from "./local-agent-claude.js"; +import { createLocalAgentDrivers } from "./local-agent-adapters.js"; +import { subagentsConfigSchema } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { @@ -230,3 +232,39 @@ await assert.rejects( TypeError, "programmer defects must not be reclassified as provider failures", ); + +let configuredOptions: Record | undefined; +const configuredDriver = createLocalAgentDrivers({ + env: { + PATH: "/usr/bin", + CLAUDE_COMMAND: "/usr/bin/claude", + ANTHROPIC_API_KEY: "inherited", + INHERITED: "yes", + }, + subagents: subagentsConfigSchema.parse({ + enabled: true, + providers: [{ + id: "claude", + enabled: true, + command: "/opt/bin/claude-wrapper", + env: { ANTHROPIC_API_KEY: "configured", EMPTY_VALUE: "" }, + }], + }), + claudeQueryFactory: ({ prompt, options }) => { + configuredOptions = options; + return new FakeClaudeQuery(prompt); + }, +}).find((driver) => driver.provider === "claude"); +assert.ok(configuredDriver); +const configuredRuntime = await configuredDriver.createRuntime(context); +assert.equal(configuredRuntime.isOk(), true); +if (configuredRuntime.isErr()) throw configuredRuntime.error; +assert.equal(configuredOptions?.pathToClaudeCodeExecutable, "/opt/bin/claude-wrapper"); +assert.deepEqual(configuredOptions?.env, { + PATH: "/usr/bin", + CLAUDE_COMMAND: "/opt/bin/claude-wrapper", + ANTHROPIC_API_KEY: "configured", + INHERITED: "yes", + EMPTY_VALUE: "", +}); +await configuredRuntime.value.close(); diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index e37ceafa..a27812a1 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { isSubagentProviderEnabled, + localAgentProviderEnvironment, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; @@ -8,14 +9,28 @@ import { const config = subagentsConfigSchema.parse({ enabled: true, providers: [ - { id: "codex", enabled: true, model: " gpt-5.4 ", effort: " high " }, + { + id: "codex", + enabled: true, + model: " gpt-5.4 ", + effort: " high ", + command: " /opt/bin/codex-wrapper ", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: false, model: "sonnet" }, ], }); assert.deepEqual(config, { enabled: true, providers: [ - { id: "codex", enabled: true, model: "gpt-5.4", effort: "high" }, + { + id: "codex", + enabled: true, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: false, model: "sonnet" }, ], }); @@ -24,6 +39,22 @@ assert.equal(isSubagentProviderEnabled(config, "claude"), false); assert.equal(isSubagentProviderEnabled(config, "pi"), false); assert.equal(subagentProviderConfig(config, "codex")?.model, "gpt-5.4"); +const inherited = { + CODEX_COMMAND: "/usr/bin/codex", + OPENAI_API_KEY: "inherited", + UNCHANGED: "yes", +}; +assert.deepEqual(localAgentProviderEnvironment(config, "codex", inherited), { + CODEX_COMMAND: "/opt/bin/codex-wrapper", + OPENAI_API_KEY: "configured", + EMPTY_VALUE: "", + UNCHANGED: "yes", +}); +assert.deepEqual(inherited, { + CODEX_COMMAND: "/usr/bin/codex", + OPENAI_API_KEY: "inherited", + UNCHANGED: "yes", +}); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -45,3 +76,26 @@ assert.throws( }), /Too small/, ); +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "codex", enabled: true, command: " " }], + }), + /non-whitespace character/, +); +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "codex", enabled: true, env: { "INVALID-NAME": "value" } }], + }), + /Invalid environment variable name/, +); +for (const id of ["opencode", "pi"] as const) { + assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id, enabled: true, command: "/opt/bin/agent" }], + }), + new RegExp(`${id} is embedded and does not support command or env configuration`), + ); +} diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 62e9c35a..ae3f4201 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -4,12 +4,30 @@ import { type LocalAgentProvider, } from "./local-agent-profiles.js"; +const environmentSchema = z.record( + z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), + z.string(), +); + const providerSchema = z.object({ id: z.enum(LOCAL_AGENT_PROVIDERS as [LocalAgentProvider, ...LocalAgentProvider[]]), enabled: z.boolean(), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), -}).strict(); + command: z.string() + .regex(/\S/, "Command must contain a non-whitespace character") + .trim() + .min(1) + .optional(), + env: environmentSchema.optional(), +}).strict().superRefine((value, context) => { + if ((value.id === "opencode" || value.id === "pi") && (value.command || value.env)) { + context.addIssue({ + code: "custom", + message: `${value.id} is embedded and does not support command or env configuration.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), @@ -50,3 +68,28 @@ export function isSubagentProviderEnabled( ): boolean { return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; } + +export function localAgentProviderEnvironment( + config: SubagentsConfig, + provider: LocalAgentProvider, + inherited: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const providerConfig = subagentProviderConfig(config, provider); + const env = { ...inherited, ...providerConfig?.env }; + const commandVariable = providerCommandVariable(provider); + if (commandVariable && providerConfig?.command) env[commandVariable] = providerConfig.command; + return env; +} + +export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { + switch (provider) { + case "codex": return "CODEX_COMMAND"; + case "claude": return "CLAUDE_COMMAND"; + case "cursor": return "CURSOR_COMMAND"; + case "copilot": return "COPILOT_COMMAND"; + case "grok": return "GROK_COMMAND"; + case "opencode": + case "pi": + return undefined; + } +} diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index b1e0e09d..0121441d 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -22,7 +22,7 @@ const log = ( const store = new LocalAgentStore(paths.stateDir); const manager = new LocalAgentManager({ store, - drivers: createLocalAgentDrivers(), + drivers: createLocalAgentDrivers({ subagents: config.subagents }), pool: new LocalAgentRuntimePool({ logger: log }), loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot, { includeDisabled: true }), agentDir: config.agentDir, diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index b4236e9c..6c57335e 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -30,7 +30,14 @@ assert.deepEqual( const configured = { enabled: true, providers: [ - { id: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high" }, + { + id: "codex" as const, + enabled: true, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude" as const, enabled: true, model: "sonnet" }, ], }; @@ -39,7 +46,14 @@ assert.deepEqual( { enabled: true, providers: [ - { id: "codex", enabled: false, model: "gpt-5.4", effort: "high" }, + { + id: "codex", + enabled: false, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: true, model: "sonnet" }, ], }, diff --git a/src/server.ts b/src/server.ts index 9e7ded7f..21a2123f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -734,11 +734,11 @@ export function createServer( const processSessions = new ProcessSessionManager(); const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const resolveLocalAgentProviders = () => buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const logSessionCloseResults = (