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:
parent
5e417b44e1
commit
1ab8796ccc
@ -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),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
170
src/agents/skills/env-overrides.skills-global-env.test.ts
Normal file
170
src/agents/skills/env-overrides.skills-global-env.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user