Skip to content

Commit e530865

Browse files
committed
fix: preserve legacy clawhub skill updates (#53206) (thanks @drobison00)
1 parent 003752b commit e530865

File tree

5 files changed

+233
-43
lines changed

5 files changed

+233
-43
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
2424
- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
2525
- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
26+
- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
2627
- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
2728
- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
2829

src/agents/skills-clawhub.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
14
import { beforeEach, describe, expect, it, vi } from "vitest";
25

36
const fetchClawHubSkillDetailMock = vi.fn();
@@ -29,7 +32,8 @@ vi.mock("../infra/archive.js", () => ({
2932
fileExists: fileExistsMock,
3033
}));
3134

32-
const { installSkillFromClawHub, searchSkillsFromClawHub } = await import("./skills-clawhub.js");
35+
const { installSkillFromClawHub, searchSkillsFromClawHub, updateSkillsFromClawHub } =
36+
await import("./skills-clawhub.js");
3337

3438
describe("skills-clawhub", () => {
3539
beforeEach(() => {
@@ -95,6 +99,127 @@ describe("skills-clawhub", () => {
9599
});
96100
});
97101

102+
describe("legacy tracked slugs remain updatable", () => {
103+
async function createLegacyTrackedSkillFixture(slug: string) {
104+
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-clawhub-"));
105+
const skillDir = path.join(workspaceDir, "skills", slug);
106+
await fs.mkdir(path.join(skillDir, ".clawhub"), { recursive: true });
107+
await fs.mkdir(path.join(workspaceDir, ".clawhub"), { recursive: true });
108+
await fs.writeFile(
109+
path.join(skillDir, ".clawhub", "origin.json"),
110+
`${JSON.stringify(
111+
{
112+
version: 1,
113+
registry: "https://legacy.clawhub.ai",
114+
slug,
115+
installedVersion: "0.9.0",
116+
installedAt: 123,
117+
},
118+
null,
119+
2,
120+
)}\n`,
121+
"utf8",
122+
);
123+
await fs.writeFile(
124+
path.join(workspaceDir, ".clawhub", "lock.json"),
125+
`${JSON.stringify(
126+
{
127+
version: 1,
128+
skills: {
129+
[slug]: {
130+
version: "0.9.0",
131+
installedAt: 123,
132+
},
133+
},
134+
},
135+
null,
136+
2,
137+
)}\n`,
138+
"utf8",
139+
);
140+
return { workspaceDir, skillDir };
141+
}
142+
143+
it("updates all tracked legacy Unicode slugs in place", async () => {
144+
const slug = "re\u0430ct";
145+
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
146+
installPackageDirMock.mockResolvedValueOnce({
147+
ok: true,
148+
targetDir: path.join(workspaceDir, "skills", slug),
149+
});
150+
151+
try {
152+
const results = await updateSkillsFromClawHub({
153+
workspaceDir,
154+
});
155+
156+
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({
157+
slug,
158+
baseUrl: "https://legacy.clawhub.ai",
159+
});
160+
expect(downloadClawHubSkillArchiveMock).toHaveBeenCalledWith({
161+
slug,
162+
version: "1.0.0",
163+
baseUrl: "https://legacy.clawhub.ai",
164+
});
165+
expect(results).toMatchObject([
166+
{
167+
ok: true,
168+
slug,
169+
previousVersion: "0.9.0",
170+
version: "1.0.0",
171+
targetDir: path.join(workspaceDir, "skills", slug),
172+
},
173+
]);
174+
} finally {
175+
await fs.rm(workspaceDir, { recursive: true, force: true });
176+
}
177+
});
178+
179+
it("updates a legacy Unicode slug when requested explicitly", async () => {
180+
const slug = "re\u0430ct";
181+
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
182+
installPackageDirMock.mockResolvedValueOnce({
183+
ok: true,
184+
targetDir: path.join(workspaceDir, "skills", slug),
185+
});
186+
187+
try {
188+
const results = await updateSkillsFromClawHub({
189+
workspaceDir,
190+
slug,
191+
});
192+
193+
expect(results).toMatchObject([
194+
{
195+
ok: true,
196+
slug,
197+
previousVersion: "0.9.0",
198+
version: "1.0.0",
199+
targetDir: path.join(workspaceDir, "skills", slug),
200+
},
201+
]);
202+
} finally {
203+
await fs.rm(workspaceDir, { recursive: true, force: true });
204+
}
205+
});
206+
207+
it("still rejects an untracked Unicode slug passed to update", async () => {
208+
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-clawhub-"));
209+
210+
try {
211+
await expect(
212+
updateSkillsFromClawHub({
213+
workspaceDir,
214+
slug: "re\u0430ct",
215+
}),
216+
).rejects.toThrow("Invalid skill slug");
217+
} finally {
218+
await fs.rm(workspaceDir, { recursive: true, force: true });
219+
}
220+
});
221+
});
222+
98223
describe("normalizeSlug rejects non-ASCII homograph slugs", () => {
99224
it("rejects Cyrillic homograph 'а' (U+0430) in slug", async () => {
100225
const result = await installSkillFromClawHub({

src/agents/skills-clawhub.ts

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,36 @@ const VALID_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
6666
// eslint-disable-next-line no-control-regex -- detects any character outside printable ASCII
6767
const NON_ASCII_PATTERN = /[^\x00-\x7F]/;
6868

69-
function normalizeSlug(raw: string): string {
69+
function normalizeTrackedSlug(raw: string): string {
7070
const slug = raw.trim();
71-
if (NON_ASCII_PATTERN.test(slug)) {
71+
if (!slug || slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
7272
throw new Error(`Invalid skill slug: ${raw}`);
7373
}
74-
if (!slug || !VALID_SLUG_PATTERN.test(slug)) {
74+
return slug;
75+
}
76+
77+
function normalizeSlug(raw: string): string {
78+
const slug = normalizeTrackedSlug(raw);
79+
if (NON_ASCII_PATTERN.test(slug) || !VALID_SLUG_PATTERN.test(slug)) {
7580
throw new Error(`Invalid skill slug: ${raw}`);
7681
}
7782
return slug;
7883
}
7984

85+
async function resolveRequestedUpdateSlug(params: {
86+
workspaceDir: string;
87+
requestedSlug: string;
88+
lock: ClawHubSkillsLockfile;
89+
}): Promise<string> {
90+
const trackedSlug = normalizeTrackedSlug(params.requestedSlug);
91+
const trackedTargetDir = resolveSkillInstallDir(params.workspaceDir, trackedSlug);
92+
const trackedOrigin = await readClawHubSkillOrigin(trackedTargetDir);
93+
if (trackedOrigin || params.lock.skills[trackedSlug]) {
94+
return trackedSlug;
95+
}
96+
return normalizeSlug(params.requestedSlug);
97+
}
98+
8099
function resolveSkillInstallDir(workspaceDir: string, slug: string): string {
81100
const skillsDir = path.join(path.resolve(workspaceDir), "skills");
82101
const target = resolveSafeInstallDir({
@@ -225,16 +244,21 @@ async function installExtractedSkill(params: {
225244
return { ok: true, targetDir };
226245
}
227246

228-
export async function installSkillFromClawHub(params: {
229-
workspaceDir: string;
230-
slug: string;
231-
version?: string;
232-
baseUrl?: string;
233-
force?: boolean;
234-
logger?: Logger;
235-
}): Promise<InstallClawHubSkillResult> {
247+
async function installSkillFromClawHubInternal(
248+
params: {
249+
workspaceDir: string;
250+
slug: string;
251+
version?: string;
252+
baseUrl?: string;
253+
force?: boolean;
254+
logger?: Logger;
255+
},
256+
options?: { allowLegacyTrackedSlug?: boolean },
257+
): Promise<InstallClawHubSkillResult> {
236258
try {
237-
const slug = normalizeSlug(params.slug);
259+
const slug = options?.allowLegacyTrackedSlug
260+
? normalizeTrackedSlug(params.slug)
261+
: normalizeSlug(params.slug);
238262
const { detail, version } = await resolveInstallVersion({
239263
slug,
240264
version: params.version,
@@ -309,14 +333,33 @@ export async function installSkillFromClawHub(params: {
309333
}
310334
}
311335

336+
export async function installSkillFromClawHub(params: {
337+
workspaceDir: string;
338+
slug: string;
339+
version?: string;
340+
baseUrl?: string;
341+
force?: boolean;
342+
logger?: Logger;
343+
}): Promise<InstallClawHubSkillResult> {
344+
return await installSkillFromClawHubInternal(params);
345+
}
346+
312347
export async function updateSkillsFromClawHub(params: {
313348
workspaceDir: string;
314349
slug?: string;
315350
baseUrl?: string;
316351
logger?: Logger;
317352
}): Promise<UpdateClawHubSkillResult[]> {
318353
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
319-
const slugs = params.slug ? [normalizeSlug(params.slug)] : Object.keys(lock.skills);
354+
const slugs = params.slug
355+
? [
356+
await resolveRequestedUpdateSlug({
357+
workspaceDir: params.workspaceDir,
358+
requestedSlug: params.slug,
359+
lock,
360+
}),
361+
]
362+
: Object.keys(lock.skills).map((slug) => normalizeTrackedSlug(slug));
320363
const results: UpdateClawHubSkillResult[] = [];
321364
for (const slug of slugs) {
322365
const targetDir = resolveSkillInstallDir(params.workspaceDir, slug);
@@ -330,13 +373,16 @@ export async function updateSkillsFromClawHub(params: {
330373
continue;
331374
}
332375
const previousVersion = origin?.installedVersion ?? lock.skills[slug]?.version ?? null;
333-
const install = await installSkillFromClawHub({
334-
workspaceDir: params.workspaceDir,
335-
slug,
336-
baseUrl,
337-
force: true,
338-
logger: params.logger,
339-
});
376+
const install = await installSkillFromClawHubInternal(
377+
{
378+
workspaceDir: params.workspaceDir,
379+
slug,
380+
baseUrl,
381+
force: true,
382+
logger: params.logger,
383+
},
384+
{ allowLegacyTrackedSlug: true },
385+
);
340386
if (!install.ok) {
341387
results.push(install);
342388
continue;

src/config/io.compat.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe("config io paths", () => {
196196
});
197197
});
198198

199-
it("still warns for same-base prerelease configs", async () => {
199+
it("does not warn for same-base prerelease configs when current version is newer", async () => {
200200
const parsedVersion = parseOpenClawVersion(VERSION);
201201
if (!parsedVersion) {
202202
throw new Error(`Unable to parse VERSION: ${VERSION}`);
@@ -225,8 +225,8 @@ describe("config io paths", () => {
225225

226226
io.loadConfig();
227227

228-
expect(logger.warn).toHaveBeenCalledWith(
229-
`Config was last written by a newer OpenClaw (${touchedVersion}); current version is ${VERSION}.`,
228+
expect(logger.warn).not.toHaveBeenCalledWith(
229+
expect.stringContaining("Config was last written by a newer OpenClaw"),
230230
);
231231
expect(io.configPath).toBe(configPath);
232232
});

src/cron/service.session-reaper-in-finally.test.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ function createDueIsolatedJob(params: { id: string; nowMs: number }): CronJob {
2929
};
3030
}
3131

32+
function clearCronTimer(state: { timer: NodeJS.Timeout | null }): void {
33+
if (state.timer) {
34+
clearTimeout(state.timer);
35+
state.timer = null;
36+
}
37+
}
38+
3239
describe("CronService - session reaper runs in finally block (#31946)", () => {
3340
beforeEach(() => {
3441
noopLogger.debug.mockClear();
@@ -72,14 +79,18 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
7279
sessionStorePath,
7380
});
7481

75-
await onTimer(state);
82+
try {
83+
await onTimer(state);
7684

77-
// After onTimer finishes (even with a job error), state.running must be
78-
// false — proving the finally block executed.
79-
expect(state.running).toBe(false);
85+
// After onTimer finishes (even with a job error), state.running must be
86+
// false — proving the finally block executed.
87+
expect(state.running).toBe(false);
8088

81-
// The timer must be re-armed.
82-
expect(state.timer).not.toBeNull();
89+
// The timer must be re-armed.
90+
expect(state.timer).not.toBeNull();
91+
} finally {
92+
clearCronTimer(state);
93+
}
8394
});
8495

8596
it("session reaper runs when resolveSessionStorePath is provided", async () => {
@@ -112,12 +123,16 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
112123
},
113124
});
114125

115-
await onTimer(state);
126+
try {
127+
await onTimer(state);
116128

117-
// The resolveSessionStorePath callback should have been invoked to build
118-
// the set of store paths for the session reaper.
119-
expect(resolvedPaths.length).toBeGreaterThan(0);
120-
expect(state.running).toBe(false);
129+
// The resolveSessionStorePath callback should have been invoked to build
130+
// the set of store paths for the session reaper.
131+
expect(resolvedPaths.length).toBeGreaterThan(0);
132+
expect(state.running).toBe(false);
133+
} finally {
134+
clearCronTimer(state);
135+
}
121136
});
122137

123138
it("prunes expired cron-run sessions even when cron store load throws", async () => {
@@ -153,13 +168,16 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
153168
sessionStorePath,
154169
});
155170

156-
await expect(onTimer(state)).rejects.toThrow("Failed to parse cron store");
157-
158-
const updatedSessionStore = JSON.parse(await fs.readFile(sessionStorePath, "utf-8")) as Record<
159-
string,
160-
unknown
161-
>;
162-
expect(updatedSessionStore).toEqual({});
163-
expect(state.running).toBe(false);
171+
try {
172+
await expect(onTimer(state)).rejects.toThrow("Failed to parse cron store");
173+
174+
const updatedSessionStore = JSON.parse(
175+
await fs.readFile(sessionStorePath, "utf-8"),
176+
) as Record<string, unknown>;
177+
expect(updatedSessionStore).toEqual({});
178+
expect(state.running).toBe(false);
179+
} finally {
180+
clearCronTimer(state);
181+
}
164182
});
165183
});

0 commit comments

Comments
 (0)