Skip to content

Commit 6799803

Browse files
author
Dan Clawd
committed
fix(skills): refresh snapshots after skill exposure config changes
1 parent 4725233 commit 6799803

9 files changed

Lines changed: 166 additions & 73 deletions

File tree

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22
import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END } from "./internal-events.js";
33
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
4+
import { fingerprintSkillSnapshotConfig } from "./skills/snapshot-fingerprint.js";
45

56
const state = vi.hoisted(() => ({
67
defaultRuntimeConfig: {
@@ -1088,6 +1089,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
10881089
prompt: "persisted prompt",
10891090
skills: [{ name: "cli-skill" }],
10901091
skillFilter: ["cli-skill"],
1092+
configFingerprint: fingerprintSkillSnapshotConfig(state.defaultRuntimeConfig),
10911093
version: 0,
10921094
};
10931095
const rebuiltSkills = [

src/agents/agent-command.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-codex-routing.js";
8080
import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js";
8181
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
82+
import { fingerprintSkillSnapshotConfig } from "./skills/snapshot-fingerprint.js";
8283
import { hydrateResolvedSkillsAsync } from "./skills/snapshot-hydration.js";
8384
import { normalizeSpawnedRunMetadata } from "./spawned-context.js";
8485
import { resolveAgentTimeoutMs } from "./timeout.js";
@@ -642,11 +643,13 @@ async function agentCommandInternal(
642643
await Promise.all([loadSkillsRefreshStateRuntime(), loadSkillsFilterRuntime()]);
643644
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
644645
const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId);
646+
const configFingerprint = fingerprintSkillSnapshotConfig(cfg);
645647
const currentSkillsSnapshot = sessionEntry?.skillsSnapshot;
646648
const shouldRefreshSkillsSnapshot =
647649
!currentSkillsSnapshot ||
648650
shouldRefreshSnapshotForVersion(currentSkillsSnapshot.version, skillsSnapshotVersion) ||
649-
!matchesSkillFilter(currentSkillsSnapshot.skillFilter, skillFilter);
651+
!matchesSkillFilter(currentSkillsSnapshot.skillFilter, skillFilter) ||
652+
currentSkillsSnapshot.configFingerprint !== configFingerprint;
650653
const needsSkillsSnapshot = isNewSession || shouldRefreshSkillsSnapshot;
651654
const buildSkillsSnapshot = async () => {
652655
const [

src/agents/skills.buildworkspaceskillsnapshot.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,43 @@ describe("buildWorkspaceSkillSnapshot", () => {
108108
expect(snapshot.skills).toStrictEqual([]);
109109
});
110110

111+
it("exposes shared x-post-analysis to HQ agent workspaces through skills.load.extraDirs", async () => {
112+
const workspaceDir = await fixtureSuite.createCaseDir("HQ Agents/tom");
113+
const sharedSkillsDir = await fixtureSuite.createCaseDir("shared-skills");
114+
115+
await writeSkill({
116+
dir: path.join(workspaceDir, "skills", "xurl"),
117+
name: "xurl",
118+
description: "Generic X API access",
119+
});
120+
await writeSkill({
121+
dir: path.join(sharedSkillsDir, "x-post-analysis"),
122+
name: "x-post-analysis",
123+
description: "Deterministic X post analysis workflow",
124+
});
125+
126+
const withoutShared = buildSnapshot(workspaceDir);
127+
expectSnapshotNamesAndPrompt(withoutShared, {
128+
contains: ["xurl"],
129+
omits: ["x-post-analysis"],
130+
});
131+
132+
const withShared = buildSnapshot(workspaceDir, {
133+
config: {
134+
skills: {
135+
load: {
136+
extraDirs: [sharedSkillsDir],
137+
},
138+
},
139+
},
140+
});
141+
142+
expectSnapshotNamesAndPrompt(withShared, {
143+
contains: ["x-post-analysis", "xurl"],
144+
});
145+
expect(withShared.configFingerprint).toEqual(expect.any(String));
146+
});
147+
111148
it("omits disable-model-invocation skills from the prompt", async () => {
112149
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
113150
await writeSkill({
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import crypto from "node:crypto";
2+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
3+
import { stableStringify } from "../stable-stringify.js";
4+
5+
function isSensitiveConfigKey(key: string): boolean {
6+
const normalized = key.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
7+
return (
8+
normalized.endsWith("apikey") ||
9+
normalized.endsWith("token") ||
10+
normalized.endsWith("secret") ||
11+
normalized.endsWith("password") ||
12+
normalized.endsWith("privatekey") ||
13+
normalized.endsWith("clientsecret")
14+
);
15+
}
16+
17+
function redactSensitiveConfigValue(value: unknown): unknown {
18+
if (value === undefined || value === null || value === false || value === "") {
19+
return value;
20+
}
21+
if (typeof value === "string") {
22+
return value.trim() ? "[redacted:string]" : "";
23+
}
24+
if (typeof value === "number") {
25+
return Number.isFinite(value) && value !== 0 ? "[redacted:number]" : value;
26+
}
27+
if (typeof value === "boolean") {
28+
return value;
29+
}
30+
if (Array.isArray(value)) {
31+
return value.length === 0 ? [] : "[redacted:array]";
32+
}
33+
return "[redacted:object]";
34+
}
35+
36+
function redactConfigForSkillSnapshot(value: unknown, stack = new WeakSet<object>()): unknown {
37+
if (!value || typeof value !== "object") {
38+
return value;
39+
}
40+
if (stack.has(value)) {
41+
return "[Circular]";
42+
}
43+
stack.add(value);
44+
try {
45+
if (Array.isArray(value)) {
46+
return value.map((entry) => redactConfigForSkillSnapshot(entry, stack));
47+
}
48+
const redacted: Record<string, unknown> = {};
49+
for (const key of Object.keys(value as Record<string, unknown>).toSorted()) {
50+
const field = (value as Record<string, unknown>)[key];
51+
redacted[key] = isSensitiveConfigKey(key)
52+
? redactSensitiveConfigValue(field)
53+
: redactConfigForSkillSnapshot(field, stack);
54+
}
55+
return redacted;
56+
} finally {
57+
stack.delete(value);
58+
}
59+
}
60+
61+
// Skill frontmatter `requires.config`, configured skill env, filters, and
62+
// skills.load.extraDirs all read the OpenClaw config. Persisting a redacted
63+
// fingerprint with the session snapshot makes config-driven skill exposure
64+
// changes deterministic across gateway restarts without storing secrets.
65+
export function fingerprintSkillSnapshotConfig(config?: OpenClawConfig): string {
66+
return crypto
67+
.createHash("sha256")
68+
.update(stableStringify(redactConfigForSkillSnapshot(config ?? {})))
69+
.digest("hex");
70+
}

src/agents/skills/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export type SkillSnapshot = {
9999
skills: Array<{ name: string; primaryEnv?: string; requiredEnv?: string[] }>;
100100
/** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */
101101
skillFilter?: string[];
102+
/** Redacted config fingerprint used to invalidate stale persisted snapshots after config-driven skill exposure changes. */
103+
configFingerprint?: string;
102104
resolvedSkills?: Skill[];
103105
version?: number;
104106
};

src/agents/skills/workspace.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { loadSkillsFromDirSafe, readSkillFrontmatterSafe } from "./local-loader.
2121
import { resolvePluginSkillDirs } from "./plugin-skills.js";
2222
import { serializeByKey } from "./serialize.js";
2323
import { formatSkillsForPrompt, type Skill } from "./skill-contract.js";
24+
import { fingerprintSkillSnapshotConfig } from "./snapshot-fingerprint.js";
2425
import type {
2526
ParsedSkillFrontmatter,
2627
SkillEligibilityContext,
@@ -1053,6 +1054,7 @@ export function buildWorkspaceSkillSnapshot(
10531054
requiredEnv: entry.metadata?.requires?.env?.slice(),
10541055
})),
10551056
...(skillFilter === undefined ? {} : { skillFilter }),
1057+
configFingerprint: fingerprintSkillSnapshotConfig(opts?.config),
10561058
resolvedSkills,
10571059
version: opts?.snapshotVersion,
10581060
};

src/auto-reply/reply/session-updates.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { fingerprintSkillSnapshotConfig } from "../../agents/skills/snapshot-fingerprint.js";
23
import type { SessionEntry } from "../../config/sessions.js";
4+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
35

46
const TEST_WORKSPACE_DIR = "/tmp/workspace";
57
type TestSkillSnapshot = NonNullable<SessionEntry["skillsSnapshot"]>;
68

7-
function strippedSnapshot(skillName = "test"): TestSkillSnapshot {
9+
function strippedSnapshot(skillName = "test", config: OpenClawConfig = {}): TestSkillSnapshot {
810
return {
911
prompt: "skills prompt",
1012
skills: [{ name: skillName }],
13+
configFingerprint: fingerprintSkillSnapshotConfig(config),
1114
version: 0,
1215
};
1316
}
@@ -236,6 +239,42 @@ describe("ensureSkillSnapshot", () => {
236239
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(2);
237240
});
238241

242+
it("refreshes a stale persisted snapshot after config-driven shared skill exposure changes", async () => {
243+
vi.stubEnv("OPENCLAW_TEST_FAST", "0");
244+
245+
buildWorkspaceSkillSnapshotMock.mockImplementation((_workspaceDir, opts) => {
246+
const config = (opts as { config?: OpenClawConfig }).config;
247+
return {
248+
prompt: "x-post-analysis prompt",
249+
skills: [{ name: "x-post-analysis" }, { name: "xurl" }],
250+
configFingerprint: fingerprintSkillSnapshotConfig(config),
251+
resolvedSkills: [{ name: "x-post-analysis" }, { name: "xurl" }],
252+
version: 0,
253+
};
254+
});
255+
256+
const staleSnapshot: TestSkillSnapshot = {
257+
prompt: "old xurl-only prompt",
258+
skills: [{ name: "xurl" }],
259+
version: 0,
260+
};
261+
262+
const result = await ensureSkillSnapshot({
263+
sessionEntry: testSessionEntry("sess-1", staleSnapshot),
264+
sessionStore: {},
265+
sessionKey: "agent:tom:telegram:direct:123",
266+
isFirstTurnInSession: false,
267+
workspaceDir: TEST_WORKSPACE_DIR,
268+
cfg: { skills: { load: { extraDirs: ["/root/clawd/skills"] } } },
269+
});
270+
271+
expect(result.skillsSnapshot?.skills.map((skill) => skill.name)).toEqual([
272+
"x-post-analysis",
273+
"xurl",
274+
]);
275+
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
276+
});
277+
239278
it("redacts secret values in the cache key while preserving eligibility presence", async () => {
240279
vi.stubEnv("OPENCLAW_TEST_FAST", "0");
241280

@@ -245,15 +284,16 @@ describe("ensureSkillSnapshot", () => {
245284
resolvedSkills: [{ name: "discord" }],
246285
});
247286

248-
const snapshot = strippedSnapshot("discord");
287+
const firstConfig = { channels: { discord: { token: "first-secret" } } };
288+
const snapshot = strippedSnapshot("discord", firstConfig);
249289

250290
await ensureSkillSnapshot({
251291
sessionEntry: testSessionEntry("sess-1", snapshot),
252292
sessionStore: {},
253293
sessionKey: "main",
254294
isFirstTurnInSession: true,
255295
workspaceDir: TEST_WORKSPACE_DIR,
256-
cfg: { channels: { discord: { token: "first-secret" } } },
296+
cfg: firstConfig,
257297
});
258298

259299
await ensureSkillSnapshot({

src/auto-reply/reply/session-updates.ts

Lines changed: 4 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import crypto from "node:crypto";
21
import fs from "node:fs";
32
import path from "node:path";
43
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
@@ -10,8 +9,8 @@ import {
109
shouldRefreshSnapshotForVersion,
1110
} from "../../agents/skills/refresh-state.js";
1211
import { ensureSkillsWatcher } from "../../agents/skills/refresh.js";
12+
import { fingerprintSkillSnapshotConfig } from "../../agents/skills/snapshot-fingerprint.js";
1313
import { hydrateResolvedSkills } from "../../agents/skills/snapshot-hydration.js";
14-
import { stableStringify } from "../../agents/stable-stringify.js";
1514
import {
1615
resolveSessionFilePath,
1716
resolveSessionFilePathOptions,
@@ -42,71 +41,6 @@ export function __testing_resetResolvedSkillsCache(): void {
4241
resolvedSkillsCache.clear();
4342
}
4443

45-
function isSensitiveConfigKey(key: string): boolean {
46-
const normalized = key.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
47-
return (
48-
normalized.endsWith("apikey") ||
49-
normalized.endsWith("token") ||
50-
normalized.endsWith("secret") ||
51-
normalized.endsWith("password") ||
52-
normalized.endsWith("privatekey") ||
53-
normalized.endsWith("clientsecret")
54-
);
55-
}
56-
57-
function redactSensitiveConfigValue(value: unknown): unknown {
58-
if (value === undefined || value === null || value === false || value === "") {
59-
return value;
60-
}
61-
if (typeof value === "string") {
62-
return value.trim() ? "[redacted:string]" : "";
63-
}
64-
if (typeof value === "number") {
65-
return Number.isFinite(value) && value !== 0 ? "[redacted:number]" : value;
66-
}
67-
if (typeof value === "boolean") {
68-
return value;
69-
}
70-
if (Array.isArray(value)) {
71-
return value.length === 0 ? [] : "[redacted:array]";
72-
}
73-
return "[redacted:object]";
74-
}
75-
76-
function redactConfigForSkillSnapshotCache(value: unknown, stack = new WeakSet<object>()): unknown {
77-
if (!value || typeof value !== "object") {
78-
return value;
79-
}
80-
if (stack.has(value)) {
81-
return "[Circular]";
82-
}
83-
stack.add(value);
84-
try {
85-
if (Array.isArray(value)) {
86-
return value.map((entry) => redactConfigForSkillSnapshotCache(entry, stack));
87-
}
88-
const redacted: Record<string, unknown> = {};
89-
for (const key of Object.keys(value as Record<string, unknown>).toSorted()) {
90-
const field = (value as Record<string, unknown>)[key];
91-
redacted[key] = isSensitiveConfigKey(key)
92-
? redactSensitiveConfigValue(field)
93-
: redactConfigForSkillSnapshotCache(field, stack);
94-
}
95-
return redacted;
96-
} finally {
97-
stack.delete(value);
98-
}
99-
}
100-
101-
// Skill frontmatter `requires.config` reads the full OpenClaw config, so cache
102-
// reuse must follow the same boundary without putting raw secrets in Map keys.
103-
function fingerprintSkillSnapshotConfig(config: OpenClawConfig): string {
104-
return crypto
105-
.createHash("sha256")
106-
.update(stableStringify(redactConfigForSkillSnapshotCache(config)))
107-
.digest("hex");
108-
}
109-
11044
function cacheResolvedSkills(cacheKey: string, snapshot: SkillSnapshot): SkillSnapshot {
11145
resolvedSkillsCache.set(cacheKey, snapshot.resolvedSkills);
11246
if (resolvedSkillsCache.size > RESOLVED_SKILLS_CACHE_MAX) {
@@ -261,9 +195,11 @@ export async function ensureSkillSnapshot(params: {
261195
const snapshotVersion = getSkillsSnapshotVersion(workspaceDir);
262196
const existingSnapshot = nextEntry?.skillsSnapshot;
263197
ensureSkillsWatcher({ workspaceDir, config: cfg });
198+
const configFingerprint = fingerprintSkillSnapshotConfig(cfg);
264199
const shouldRefreshSnapshot =
265200
shouldRefreshSnapshotForVersion(existingSnapshot?.version, snapshotVersion) ||
266-
!matchesSkillFilter(existingSnapshot?.skillFilter, skillFilter);
201+
!matchesSkillFilter(existingSnapshot?.skillFilter, skillFilter) ||
202+
existingSnapshot?.configFingerprint !== configFingerprint;
267203
const buildSnapshot = () => {
268204
return buildWorkspaceSkillSnapshot(workspaceDir, {
269205
config: cfg,
@@ -274,7 +210,6 @@ export async function ensureSkillSnapshot(params: {
274210
});
275211
};
276212

277-
const configFingerprint = fingerprintSkillSnapshotConfig(cfg);
278213
const snapshotCacheKey = JSON.stringify([
279214
workspaceDir,
280215
snapshotVersion,

src/config/sessions/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,8 @@ export type SessionSkillSnapshot = {
574574
skills: Array<{ name: string; primaryEnv?: string; requiredEnv?: string[] }>;
575575
/** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */
576576
skillFilter?: string[];
577+
/** Redacted config fingerprint used to invalidate stale persisted snapshots after config-driven skill exposure changes. */
578+
configFingerprint?: string;
577579
/**
578580
* Runtime-only, never persisted. Carries the full parsed Skill[] (including
579581
* each SKILL.md body) so the embedded runner can skip a workspace skill

0 commit comments

Comments
 (0)