feat(skills): add global env overrides shared across all skills

Add a top-level `skills.env` field in openclaw.json that provides
environment variable defaults shared across all skills. Individual
skill-level `env` entries still take precedence (skill > global).

Problem:
Users with multiple skills that share common env vars (e.g. OPENAI_API_KEY)
had to either repeat the key under each skill's `entries` block, or use
a .env file which is not visible in the JSON config and lacks per-skill
override capability.

Solution:
  {
    "skills": {
      "env": { "OPENAI_API_KEY": "sk-..." },
      "entries": {
        "my-skill": {
          "env": { "MY_SKILL_KEY": "..." }
        }
      }
    }
  }

Changes:
- src/config/types.skills.ts: add `env?` to SkillsConfig type
- src/config/zod-schema.ts: add `env` field to skills Zod schema (strict)
- src/agents/skills/env-overrides.ts: merge global env before skill-level
  env; add global env keys to allowedSensitiveKeys so API keys pass
  sanitization; thread globalEnv through both apply functions
- src/agents/skills/config.ts: hasEnv check now also resolves global env

Testing:
5 new unit tests in env-overrides.skills-global-env.test.ts:
- global env injected into process.env
- skill-level env overrides global for same key
- global env injected even when skill has no entries config
- global env does not override existing process.env values
- global env reverted after skill deactivation
This commit is contained in:
杨艺韬(yangyitao) 2026-03-13 10:13:58 +00:00
parent 5e417b44e1
commit 1ab8796ccc
6 changed files with 240 additions and 8 deletions

View File

@ -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),
);

View File

@ -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),
),

View File

@ -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> = {}): 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();
});
});

View File

@ -154,6 +154,8 @@ function applySkillConfigEnvOverrides(params: {
}
const pendingOverrides: Record<string, string> = {};
// 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<string, string> }) {
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);
}

View File

@ -44,4 +44,15 @@ export type SkillsConfig = {
install?: SkillsInstallConfig;
limits?: SkillsLimitsConfig;
entries?: Record<string, SkillConfig>;
/**
* 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<string, string>;
};

View File

@ -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(),