diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 02ff68e2efc..65434a49d4d 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -182,6 +182,7 @@ function buildSkillStatus( const isEnvSatisfied = (envName: string) => Boolean( process.env[envName] || + config?.skills?.env?.[envName] || skillConfig?.env?.[envName] || (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), ); diff --git a/src/agents/skills/config.ts b/src/agents/skills/config.ts index 2dfe78acd5c..031f70bce4e 100644 --- a/src/agents/skills/config.ts +++ b/src/agents/skills/config.ts @@ -95,6 +95,7 @@ export function shouldIncludeSkill(params: { hasEnv: (envName) => Boolean( process.env[envName] || + config?.skills?.env?.[envName] || skillConfig?.env?.[envName] || (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), ), diff --git a/src/agents/skills/env-overrides.skills-global-env.test.ts b/src/agents/skills/env-overrides.skills-global-env.test.ts new file mode 100644 index 00000000000..750996840c8 --- /dev/null +++ b/src/agents/skills/env-overrides.skills-global-env.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { applySkillEnvOverrides, applySkillEnvOverridesFromSnapshot } from "./env-overrides.js"; +import type { SkillEntry } from "./types.js"; + +function makeSkillEntry(name: string): SkillEntry { + return { + skill: { name, source: "workspace", path: `/skills/${name}` }, + metadata: { primaryEnv: undefined, requires: undefined, always: false }, + } as unknown as SkillEntry; +} + +function makeConfig(overrides: Partial = {}): OpenClawConfig { + return overrides as OpenClawConfig; +} + +describe("skills global env", () => { + beforeEach(() => { + delete process.env["GLOBAL_API_KEY"]; + delete process.env["SKILL_API_KEY"]; + delete process.env["SHARED_KEY"]; + delete process.env["SNAPSHOT_KEY"]; + }); + + afterEach(() => { + delete process.env["GLOBAL_API_KEY"]; + delete process.env["SKILL_API_KEY"]; + delete process.env["SHARED_KEY"]; + delete process.env["SNAPSHOT_KEY"]; + }); + + it("injects global env vars into process.env for all skills", () => { + const config = makeConfig({ + skills: { + env: { GLOBAL_API_KEY: "global-value" }, + entries: { "my-skill": {} }, + }, + }); + + const revert = applySkillEnvOverrides({ + skills: [makeSkillEntry("my-skill")], + config, + }); + + expect(process.env["GLOBAL_API_KEY"]).toBe("global-value"); + revert(); + expect(process.env["GLOBAL_API_KEY"]).toBeUndefined(); + }); + + it("skill-level env overrides global env for the same key", () => { + const config = makeConfig({ + skills: { + env: { SHARED_KEY: "global-value" }, + entries: { + "my-skill": { env: { SHARED_KEY: "skill-value" } }, + }, + }, + }); + + const revert = applySkillEnvOverrides({ + skills: [makeSkillEntry("my-skill")], + config, + }); + + expect(process.env["SHARED_KEY"]).toBe("skill-value"); + revert(); + expect(process.env["SHARED_KEY"]).toBeUndefined(); + }); + + it("global env is injected even when skill has no entries config", () => { + const config = makeConfig({ + skills: { + env: { GLOBAL_API_KEY: "global-value" }, + }, + }); + + const revert = applySkillEnvOverrides({ + skills: [makeSkillEntry("unknown-skill")], + config, + }); + + expect(process.env["GLOBAL_API_KEY"]).toBe("global-value"); + revert(); + expect(process.env["GLOBAL_API_KEY"]).toBeUndefined(); + }); + + it("global env does not override existing process.env values", () => { + process.env["GLOBAL_API_KEY"] = "existing-value"; + + const config = makeConfig({ + skills: { + env: { GLOBAL_API_KEY: "global-value" }, + entries: { "my-skill": {} }, + }, + }); + + const revert = applySkillEnvOverrides({ + skills: [makeSkillEntry("my-skill")], + config, + }); + + expect(process.env["GLOBAL_API_KEY"]).toBe("existing-value"); + revert(); + expect(process.env["GLOBAL_API_KEY"]).toBe("existing-value"); + }); + + it("reverts global env after skill deactivation", () => { + const config = makeConfig({ + skills: { + env: { GLOBAL_API_KEY: "global-value" }, + entries: { "my-skill": {} }, + }, + }); + + const revert = applySkillEnvOverrides({ + skills: [makeSkillEntry("my-skill")], + config, + }); + + expect(process.env["GLOBAL_API_KEY"]).toBe("global-value"); + revert(); + expect(process.env["GLOBAL_API_KEY"]).toBeUndefined(); + }); + + it("skill-level apiKey takes precedence over global env for the same primary env key", () => { + const config = makeConfig({ + skills: { + env: { SKILL_API_KEY: "global-fallback" }, + entries: { + "my-skill": { apiKey: "skill-apikey-value" }, + }, + }, + }); + + const entry = { + ...makeSkillEntry("my-skill"), + metadata: { primaryEnv: "SKILL_API_KEY", requires: undefined, always: false }, + } as unknown as import("./types.js").SkillEntry; + + const revert = applySkillEnvOverrides({ skills: [entry], config }); + + expect(process.env["SKILL_API_KEY"]).toBe("skill-apikey-value"); + revert(); + expect(process.env["SKILL_API_KEY"]).toBeUndefined(); + }); + + it("applySkillEnvOverridesFromSnapshot injects global env for a skill with no entries config", () => { + const config = makeConfig({ + skills: { + env: { SNAPSHOT_KEY: "snapshot-global-value" }, + }, + }); + + const snapshot = { + skills: [ + { + name: "snapshot-skill", + primaryEnv: undefined, + requiredEnv: [], + }, + ], + } as unknown as import("./types.js").SkillSnapshot; + + const revert = applySkillEnvOverridesFromSnapshot({ snapshot, config }); + + expect(process.env["SNAPSHOT_KEY"]).toBe("snapshot-global-value"); + revert(); + expect(process.env["SNAPSHOT_KEY"]).toBeUndefined(); + }); +}); diff --git a/src/agents/skills/env-overrides.ts b/src/agents/skills/env-overrides.ts index f06ff942f8a..621954ff2ee 100644 --- a/src/agents/skills/env-overrides.ts +++ b/src/agents/skills/env-overrides.ts @@ -154,6 +154,8 @@ function applySkillConfigEnvOverrides(params: { } const pendingOverrides: Record = {}; + + // Step 1 — skill-level env (highest user-configured precedence). if (skillConfig.env) { for (const [rawKey, envValue] of Object.entries(skillConfig.env)) { const envKey = rawKey.trim(); @@ -166,6 +168,7 @@ function applySkillConfigEnvOverrides(params: { } } + // Step 2 — apiKey fallback for the skill's primary env var. const resolvedApiKey = normalizeResolvedSecretInputString({ value: skillConfig.apiKey, @@ -210,16 +213,56 @@ function createEnvReverter(updates: EnvUpdate[]) { }; } +/** + * Second-pass: inject global skills.env defaults for any keys not already + * acquired by a per-skill override. Running this after all per-skill overrides + * ensures skill-level env always wins, regardless of iteration order. + */ +function applyGlobalEnvPass(params: { updates: EnvUpdate[]; globalEnv: Record }) { + const { updates, globalEnv } = params; + if (Object.keys(globalEnv).length === 0) { + return; + } + // All global env keys are explicitly user-configured, so allow sensitive names. + const allowedSensitiveKeys = new Set( + Object.keys(globalEnv) + .map((k) => k.trim()) + .filter(Boolean), + ); + const sanitized = sanitizeSkillEnvOverrides({ + overrides: globalEnv, + allowedSensitiveKeys, + }); + + for (const [envKey, envValue] of Object.entries(sanitized.allowed)) { + // Skip only if the key is externally managed (set outside our ref-counting system). + // Keys already active from a skill override are still acquired here so that the + // ref-count is incremented — this prevents a concurrent session's revert from + // releasing the variable while this session is still running. + if (process.env[envKey] !== undefined && !activeSkillEnvEntries.has(envKey)) { + continue; + } + if (!acquireActiveSkillEnvKey(envKey, envValue)) { + continue; + } + updates.push({ key: envKey }); + // If a skill already owns this key, acquireActiveSkillEnvKey keeps its value; + // process.env already reflects the skill's value, so no assignment needed. + if (!activeSkillEnvEntries.get(envKey) || process.env[envKey] === undefined) { + process.env[envKey] = activeSkillEnvEntries.get(envKey)?.value ?? envValue; + } + } +} + export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: OpenClawConfig }) { const { skills, config } = params; const updates: EnvUpdate[] = []; + const globalEnv = config?.skills?.env ?? {}; + // Pass 1: per-skill env and apiKey overrides (higher precedence than global). for (const entry of skills) { const skillKey = resolveSkillKey(entry.skill, entry); - const skillConfig = resolveSkillConfig(config, skillKey); - if (!skillConfig) { - continue; - } + const skillConfig = resolveSkillConfig(config, skillKey) ?? {}; applySkillConfigEnvOverrides({ updates, @@ -230,6 +273,9 @@ export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: }); } + // Pass 2: global env defaults — only fills keys not set by any skill above. + applyGlobalEnvPass({ updates, globalEnv }); + return createEnvReverter(updates); } @@ -242,12 +288,11 @@ export function applySkillEnvOverridesFromSnapshot(params: { return () => {}; } const updates: EnvUpdate[] = []; + const globalEnv = config?.skills?.env ?? {}; + // Pass 1: per-skill env and apiKey overrides. for (const skill of snapshot.skills) { - const skillConfig = resolveSkillConfig(config, skill.name); - if (!skillConfig) { - continue; - } + const skillConfig = resolveSkillConfig(config, skill.name) ?? {}; applySkillConfigEnvOverrides({ updates, @@ -258,5 +303,8 @@ export function applySkillEnvOverridesFromSnapshot(params: { }); } + // Pass 2: global env defaults — only fills keys not set by any skill above. + applyGlobalEnvPass({ updates, globalEnv }); + return createEnvReverter(updates); } diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index c09523ba459..db8688ff3c4 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -44,4 +44,15 @@ export type SkillsConfig = { install?: SkillsInstallConfig; limits?: SkillsLimitsConfig; entries?: Record; + /** + * Global environment variable overrides shared across all skills. + * Individual skill `env` entries take precedence over these globals. + * Useful for API keys or shared config that multiple skills need. + * + * @example + * ```json + * { "skills": { "env": { "OPENAI_API_KEY": "sk-..." } } } + * ``` + */ + env?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f8ad6bfcbc9..3039cdc5eac 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -908,6 +908,7 @@ export const OpenClawSchema = z .strict() .optional(), entries: z.record(z.string(), SkillEntrySchema).optional(), + env: z.record(z.string(), z.string()).optional(), }) .strict() .optional(),