mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: install GitHub-backed ClawHub skills (#90478)
* feat: install GitHub-backed ClawHub skills * fix: satisfy ClawHub install type checks * fix: harden github-backed skill installs * fix: keep heartbeat template non-actionable * feat: support forcing pending ClawHub installs
This commit is contained in:
@@ -90,9 +90,9 @@ function readAuthProfileStoreText(agentDir) {
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?").get(
|
||||
"primary",
|
||||
);
|
||||
const row = db
|
||||
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
|
||||
.get("primary");
|
||||
return typeof row?.store_json === "string" ? row.store_json : "";
|
||||
} finally {
|
||||
db?.close();
|
||||
|
||||
@@ -224,11 +224,14 @@ function handleParentSignal(signal) {
|
||||
terminateChildGroup("SIGKILL");
|
||||
rethrowParentSignal(signal);
|
||||
}, timeoutKillGraceMs);
|
||||
parentSignalPollTimer = setInterval(() => {
|
||||
if (!childGroupExists()) {
|
||||
rethrowParentSignal(signal);
|
||||
}
|
||||
}, Math.min(50, timeoutKillGraceMs));
|
||||
parentSignalPollTimer = setInterval(
|
||||
() => {
|
||||
if (!childGroupExists()) {
|
||||
rethrowParentSignal(signal);
|
||||
}
|
||||
},
|
||||
Math.min(50, timeoutKillGraceMs),
|
||||
);
|
||||
}
|
||||
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
|
||||
@@ -125,9 +125,12 @@ function readBody(req) {
|
||||
}
|
||||
|
||||
function requestBodyTooLargeError() {
|
||||
return Object.assign(new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
return Object.assign(
|
||||
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
|
||||
{
|
||||
code: "ETOOBIG",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isRequestBodyTooLargeError(error) {
|
||||
|
||||
@@ -7,14 +7,7 @@ import { chromium } from "playwright";
|
||||
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const playwrightInstallArgs = [
|
||||
"--dir",
|
||||
"ui",
|
||||
"exec",
|
||||
"playwright",
|
||||
"install",
|
||||
"chromium",
|
||||
];
|
||||
const playwrightInstallArgs = ["--dir", "ui", "exec", "playwright", "install", "chromium"];
|
||||
const playwrightInstallWithDepsArgs = [
|
||||
"--dir",
|
||||
"ui",
|
||||
@@ -123,10 +116,7 @@ export function ensurePlaywrightChromium(options = {}) {
|
||||
|
||||
const systemExecutablePath =
|
||||
options.systemExecutablePath ?? resolveSystemChromiumExecutablePath(existsSync, spawnSync);
|
||||
if (
|
||||
systemExecutablePath &&
|
||||
canRunChromiumExecutable(systemExecutablePath, spawnSync)
|
||||
) {
|
||||
if (systemExecutablePath && canRunChromiumExecutable(systemExecutablePath, spawnSync)) {
|
||||
log(`[ui-e2e] Using system Chromium at ${systemExecutablePath}.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -157,11 +147,13 @@ export function ensurePlaywrightChromium(options = {}) {
|
||||
}
|
||||
|
||||
if (!existsSync(executablePath) || !canRunChromiumExecutable(executablePath, spawnSync)) {
|
||||
if (shouldInstallPlaywrightSystemDependencies({
|
||||
env,
|
||||
getuid: options.getuid,
|
||||
platform: options.platform,
|
||||
})) {
|
||||
if (
|
||||
shouldInstallPlaywrightSystemDependencies({
|
||||
env,
|
||||
getuid: options.getuid,
|
||||
platform: options.platform,
|
||||
})
|
||||
) {
|
||||
log(
|
||||
`[ui-e2e] Chromium is installed but still cannot start; installing Linux system dependencies.`,
|
||||
);
|
||||
|
||||
@@ -67,117 +67,121 @@ describe("gateway-hosted exec approvals", () => {
|
||||
clearSessionStoreCacheForTest();
|
||||
});
|
||||
|
||||
it("lets OpenClaw-style gateway tool calls request and wait for approval over separate connections", async () => {
|
||||
const envSnapshot = captureEnv(TEST_ENV_KEYS);
|
||||
cleanup.push(() => envSnapshot.restore());
|
||||
it(
|
||||
"lets OpenClaw-style gateway tool calls request and wait for approval over separate connections",
|
||||
async () => {
|
||||
const envSnapshot = captureEnv(TEST_ENV_KEYS);
|
||||
cleanup.push(() => envSnapshot.restore());
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approval-e2e-"));
|
||||
cleanup.push(() => fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5 }));
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approval-e2e-"));
|
||||
cleanup.push(() => fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5 }));
|
||||
|
||||
const stateDir = path.join(tempHome, ".openclaw");
|
||||
const workspaceDir = path.join(tempHome, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
const stateDir = path.join(tempHome, ".openclaw");
|
||||
const workspaceDir = path.join(tempHome, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const token = "exec-approval-e2e-token";
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
port,
|
||||
auth: { mode: "token", token },
|
||||
},
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
const port = await getFreeGatewayPort();
|
||||
const token = "exec-approval-e2e-token";
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
port,
|
||||
auth: { mode: "token", token },
|
||||
},
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||
process.env.OPENCLAW_GATEWAY_PORT = String(port);
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
|
||||
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
deferStartupSidecars: true,
|
||||
});
|
||||
cleanup.push(() => server.close());
|
||||
|
||||
const operator = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
clientName: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientDisplayName: "approval operator",
|
||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||
scopes: [ADMIN_SCOPE],
|
||||
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
|
||||
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
|
||||
});
|
||||
cleanup.push(() => disconnectGatewayClient(operator));
|
||||
|
||||
let resolveOutcome: (outcome: ExecApprovalFollowupOutcome) => void = () => {};
|
||||
const outcomePromise = new Promise<ExecApprovalFollowupOutcome>((resolve) => {
|
||||
resolveOutcome = resolve;
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
cwd: workspaceDir,
|
||||
approvalRunningNoticeMs: 0,
|
||||
approvalFollowupMode: "direct",
|
||||
approvalFollowup: ({ outcome }) => {
|
||||
resolveOutcome(outcome);
|
||||
return undefined;
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||
process.env.OPENCLAW_GATEWAY_PORT = String(port);
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
|
||||
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
clearSessionStoreCacheForTest();
|
||||
const pending = await tool.execute("exec-approval-e2e", {
|
||||
command: "printf 'smoke\\n'",
|
||||
workdir: workspaceDir,
|
||||
timeout: 5,
|
||||
});
|
||||
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
deferStartupSidecars: true,
|
||||
});
|
||||
cleanup.push(() => server.close());
|
||||
expect(pending.details.status).toBe("approval-pending");
|
||||
if (pending.details.status !== "approval-pending") {
|
||||
throw new Error("expected approval-pending exec result");
|
||||
}
|
||||
|
||||
const operator = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
clientName: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientDisplayName: "approval operator",
|
||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||
scopes: [ADMIN_SCOPE],
|
||||
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
|
||||
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
|
||||
});
|
||||
cleanup.push(() => disconnectGatewayClient(operator));
|
||||
await operator.request(
|
||||
"exec.approval.resolve",
|
||||
{ id: pending.details.approvalId, decision: "allow-once" },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
let resolveOutcome: (outcome: ExecApprovalFollowupOutcome) => void = () => {};
|
||||
const outcomePromise = new Promise<ExecApprovalFollowupOutcome>((resolve) => {
|
||||
resolveOutcome = resolve;
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
cwd: workspaceDir,
|
||||
approvalRunningNoticeMs: 0,
|
||||
approvalFollowupMode: "direct",
|
||||
approvalFollowup: ({ outcome }) => {
|
||||
resolveOutcome(outcome);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const pending = await tool.execute("exec-approval-e2e", {
|
||||
command: "printf 'smoke\\n'",
|
||||
workdir: workspaceDir,
|
||||
timeout: 5,
|
||||
});
|
||||
|
||||
expect(pending.details.status).toBe("approval-pending");
|
||||
if (pending.details.status !== "approval-pending") {
|
||||
throw new Error("expected approval-pending exec result");
|
||||
}
|
||||
|
||||
await operator.request(
|
||||
"exec.approval.resolve",
|
||||
{ id: pending.details.approvalId, decision: "allow-once" },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const outcome = await withTimeout(outcomePromise, 15_000, "approved exec outcome");
|
||||
expect(outcome.status).toBe("completed");
|
||||
expect(outcome.exitCode).toBe(0);
|
||||
expect(outcome.aggregated).toBe("smoke");
|
||||
}, EXEC_APPROVAL_E2E_TIMEOUT_MS);
|
||||
const outcome = await withTimeout(outcomePromise, 15_000, "approved exec outcome");
|
||||
expect(outcome.status).toBe("completed");
|
||||
expect(outcome.exitCode).toBe(0);
|
||||
expect(outcome.aggregated).toBe("smoke");
|
||||
},
|
||||
EXEC_APPROVAL_E2E_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -216,6 +216,16 @@ tasks:
|
||||
expect(isHeartbeatContentEffectivelyEmpty("Reminder <!-- not scaffolding -->")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for HTML comments only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("<!-- runtime template note -->")).toBe(true);
|
||||
expect(
|
||||
isHeartbeatContentEffectivelyEmpty(`<!-- runtime template note -->
|
||||
|
||||
# HEARTBEAT.md
|
||||
`),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a template includes plain instructional prose", () => {
|
||||
const defaultTemplate = `# HEARTBEAT.md
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
// Skip single-line HTML comments used by the bundled runtime template.
|
||||
if (/^<!--.*-->$/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
// Skip markdown header lines (# followed by space or EOL, ## etc)
|
||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||
|
||||
155
src/cli/skills-cli.clawhub-install.e2e.test.ts
Normal file
155
src/cli/skills-cli.clawhub-install.e2e.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function spawnOpenClaw(
|
||||
args: string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||
): Promise<{ status: number | null; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "src/entry.ts", ...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function buildGitHubSkillZip(): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
zip.file("skills-main/skills/aiq-deploy/SKILL.md", "# AIQ Deploy\n");
|
||||
zip.file("skills-main/skills/aiq-deploy/skill-card.md", "# Card\n");
|
||||
zip.file("skills-main/skills/other/SKILL.md", "# Other\n");
|
||||
return await zip.generateAsync({ type: "nodebuffer" });
|
||||
}
|
||||
|
||||
describe("openclaw skills install ClawHub GitHub-backed E2E", () => {
|
||||
it("installs from the install resolver and reports install telemetry", async () => {
|
||||
const commit = "c".repeat(40);
|
||||
const telemetryBodies: unknown[] = [];
|
||||
const requestLog: string[] = [];
|
||||
const githubZipBytes = await buildGitHubSkillZip();
|
||||
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
requestLog.push(`${req.method ?? "GET"} ${url.pathname}`);
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/v1/skills/aiq-deploy/install") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === `/NVIDIA/skills/zip/${commit}`) {
|
||||
res.writeHead(200, { "Content-Type": "application/zip" });
|
||||
res.end(githubZipBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/cli/telemetry/install") {
|
||||
telemetryBodies.push(JSON.parse(await readRequestBody(req)) as unknown);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
}
|
||||
const server = createServer((req, res) => {
|
||||
void handleRequest(req, res).catch((error: unknown) => {
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-cli-e2e-"));
|
||||
try {
|
||||
const result = await spawnOpenClaw(["skills", "install", "aiq-deploy", "--global"], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
|
||||
OPENCLAW_CLAWHUB_URL: registry,
|
||||
OPENCLAW_CLAWHUB_TOKEN: "test-token",
|
||||
OPENCLAW_CLAWHUB_GITHUB_CODELOAD_BASE_URL: registry,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "",
|
||||
CLAWDHUB_DISABLE_TELEMETRY: "",
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0);
|
||||
await expect(
|
||||
fs.readFile(path.join(stateDir, "skills", "aiq-deploy", "SKILL.md"), "utf8"),
|
||||
).resolves.toContain("# AIQ Deploy");
|
||||
await expect(
|
||||
fs.readFile(path.join(stateDir, "skills", "aiq-deploy", "skill-card.md"), "utf8"),
|
||||
).resolves.toContain("# Card");
|
||||
await expect(
|
||||
fs.readFile(path.join(stateDir, "skills", "aiq-deploy", "other", "SKILL.md")),
|
||||
).rejects.toThrow();
|
||||
if (telemetryBodies.length !== 1) {
|
||||
throw new Error(`Expected one install telemetry request, saw: ${requestLog.join(", ")}`);
|
||||
}
|
||||
expect(telemetryBodies[0]).toMatchObject({
|
||||
roots: [
|
||||
{
|
||||
skills: [{ slug: "aiq-deploy", version: commit }],
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -566,6 +566,25 @@ describe("skills cli commands", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes --force-install through for ClawHub skill installs", async () => {
|
||||
installSkillFromClawHubMock.mockResolvedValue({
|
||||
ok: true,
|
||||
slug: "calendar",
|
||||
version: "1.2.3",
|
||||
targetDir: "/tmp/workspace/skills/calendar",
|
||||
});
|
||||
|
||||
await runCommand(["skills", "install", "calendar", "--force-install"]);
|
||||
|
||||
expect(installSkillFromClawHubMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
slug: "calendar",
|
||||
forceInstall: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects using --global and --agent together for installs", async () => {
|
||||
await expect(
|
||||
runCommand(["skills", "install", "calendar", "--global", "--agent", "main"]),
|
||||
@@ -613,6 +632,30 @@ describe("skills cli commands", () => {
|
||||
expect(runtimeErrors).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("passes --force-install through for ClawHub skill updates", async () => {
|
||||
readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]);
|
||||
updateSkillsFromClawHubMock.mockResolvedValue([
|
||||
{
|
||||
ok: true,
|
||||
slug: "calendar",
|
||||
previousVersion: "1.2.2",
|
||||
version: "1.2.3",
|
||||
changed: true,
|
||||
targetDir: "/tmp/workspace/skills/calendar",
|
||||
},
|
||||
]);
|
||||
|
||||
await runCommand(["skills", "update", "--all", "--force-install"]);
|
||||
|
||||
expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
slug: undefined,
|
||||
forceInstall: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates tracked ClawHub skills in the cwd-inferred agent workspace", async () => {
|
||||
routeWorkspaceByAgent();
|
||||
resolveAgentIdByWorkspacePathMock.mockReturnValue("writer");
|
||||
|
||||
@@ -287,6 +287,11 @@ export function registerSkillsCli(program: Command) {
|
||||
.argument("<slug>", "ClawHub skill slug, git:<repo>, or local skill directory")
|
||||
.option("--version <version>", "Install a specific version")
|
||||
.option("--force", "Overwrite an existing workspace skill", false)
|
||||
.option(
|
||||
"--force-install",
|
||||
"Install a pending GitHub-backed skill before ClawHub scan completes",
|
||||
false,
|
||||
)
|
||||
.option("--global", "Install into the shared managed skills directory", false)
|
||||
.option("--agent <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
|
||||
.option("--as <slug>", "Install a git/local skill under this slug")
|
||||
@@ -296,6 +301,7 @@ export function registerSkillsCli(program: Command) {
|
||||
opts: {
|
||||
version?: string;
|
||||
force?: boolean;
|
||||
forceInstall?: boolean;
|
||||
global?: boolean;
|
||||
agent?: string;
|
||||
as?: string;
|
||||
@@ -345,6 +351,7 @@ export function registerSkillsCli(program: Command) {
|
||||
slug,
|
||||
version: opts.version,
|
||||
force: Boolean(opts.force),
|
||||
...(opts.forceInstall ? { forceInstall: true } : {}),
|
||||
logger: {
|
||||
info: (message) => defaultRuntime.log(message),
|
||||
},
|
||||
@@ -367,12 +374,17 @@ export function registerSkillsCli(program: Command) {
|
||||
.description("Update ClawHub-installed skills in the active or shared managed directory")
|
||||
.argument("[slug]", "Single skill slug")
|
||||
.option("--all", "Update all tracked ClawHub skills", false)
|
||||
.option(
|
||||
"--force-install",
|
||||
"Install a pending GitHub-backed skill before ClawHub scan completes",
|
||||
false,
|
||||
)
|
||||
.option("--global", "Update skills in the shared managed skills directory", false)
|
||||
.option("--agent <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
|
||||
.action(
|
||||
async (
|
||||
slug: string | undefined,
|
||||
opts: { all?: boolean; global?: boolean; agent?: string },
|
||||
opts: { all?: boolean; forceInstall?: boolean; global?: boolean; agent?: string },
|
||||
command: Command,
|
||||
) => {
|
||||
try {
|
||||
@@ -398,6 +410,7 @@ export function registerSkillsCli(program: Command) {
|
||||
const results = await updateSkillsFromClawHub({
|
||||
workspaceDir,
|
||||
slug,
|
||||
...(opts.forceInstall ? { forceInstall: true } : {}),
|
||||
logger: {
|
||||
info: (message) => defaultRuntime.log(message),
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
downloadClawHubPackageArchive,
|
||||
downloadClawHubSkillArchive,
|
||||
downloadClawHubSkillArchiveUrl,
|
||||
fetchClawHubSkillCard,
|
||||
fetchClawHubSkillSecurityVerdicts,
|
||||
fetchClawHubPackageArtifact,
|
||||
@@ -855,4 +856,33 @@ describe("clawhub helpers", () => {
|
||||
await expectPathMissing(archiveDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not send ambient ClawHub auth tokens to off-registry resolver archive URLs", async () => {
|
||||
process.env.OPENCLAW_CLAWHUB_TOKEN = "env-token-123";
|
||||
let requestedUrl = "";
|
||||
let requestedInit: RequestInit | undefined;
|
||||
|
||||
const archive = await downloadClawHubSkillArchiveUrl({
|
||||
baseUrl: "https://clawhub.ai",
|
||||
url: "https://codeload.github.com/NVIDIA/skills/zip/abcdef",
|
||||
fetchImpl: async (input, init) => {
|
||||
requestedUrl = input instanceof Request ? input.url : String(input);
|
||||
requestedInit = init;
|
||||
return new Response(new Uint8Array([7, 8, 9]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/zip" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(requestedUrl).toBe("https://codeload.github.com/NVIDIA/skills/zip/abcdef");
|
||||
expect(new Headers(requestedInit?.headers).get("Authorization")).toBeNull();
|
||||
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from([7, 8, 9]));
|
||||
} finally {
|
||||
const archiveDir = path.dirname(archive.archivePath);
|
||||
await archive.cleanup();
|
||||
await expectPathMissing(archiveDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createTempDownloadTarget } from "./temp-download.js";
|
||||
export { parseClawHubPluginSpec } from "./clawhub-spec.js";
|
||||
|
||||
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
|
||||
const DEFAULT_GITHUB_CODELOAD_URL = "https://codeload.github.com";
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
const SKILL_CARD_MAX_BYTES = 256 * 1024;
|
||||
|
||||
@@ -306,6 +307,36 @@ export type ClawHubSkillDetail = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ClawHubSkillInstallResolutionResponse =
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "archive";
|
||||
archive: {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "github";
|
||||
github: {
|
||||
repo: string;
|
||||
path: string;
|
||||
commit: string;
|
||||
contentHash: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
slug: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type ClawHubSkillVerificationDecision = "pass" | "fail" | (string & {});
|
||||
|
||||
export type ClawHubSkillVerificationResponse = {
|
||||
@@ -389,6 +420,10 @@ export type ClawHubDownloadResult = {
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ClawHubInstallTelemetrySkill = {
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
type ClawHubRequestParams = {
|
||||
@@ -438,6 +473,14 @@ function normalizeBaseUrl(baseUrl?: string): string {
|
||||
return value || DEFAULT_CLAWHUB_URL;
|
||||
}
|
||||
|
||||
function normalizeGitHubCodeloadBaseUrl(): string {
|
||||
const value =
|
||||
normalizeOptionalString(process.env.OPENCLAW_CLAWHUB_GITHUB_CODELOAD_BASE_URL) ||
|
||||
normalizeOptionalString(process.env.CLAWHUB_GITHUB_CODELOAD_BASE_URL) ||
|
||||
DEFAULT_GITHUB_CODELOAD_URL;
|
||||
return value.replace(/\/+$/, "") || DEFAULT_GITHUB_CODELOAD_URL;
|
||||
}
|
||||
|
||||
function extractTokenFromClawHubConfig(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
@@ -787,6 +830,17 @@ function buildVersionOrTagSearch(params: {
|
||||
return tag ? { tag } : undefined;
|
||||
}
|
||||
|
||||
function buildGitHubZipUrl(repo: string, commit: string): string {
|
||||
const url = new URL(`${normalizeGitHubCodeloadBaseUrl()}/`);
|
||||
const basePath = url.pathname.replace(/\/+$/, "");
|
||||
const repoPath = repo
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
url.pathname = `${basePath}/${repoPath}/zip/${encodeURIComponent(commit)}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function formatSha256Integrity(bytes: Uint8Array): string {
|
||||
const digest = createHash("sha256").update(bytes).digest("base64");
|
||||
return `sha256-${digest}`;
|
||||
@@ -1006,6 +1060,35 @@ export async function fetchClawHubSkillDetail(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClawHubSkillInstallResolution(params: {
|
||||
slug: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: FetchLike;
|
||||
forceInstall?: boolean;
|
||||
}): Promise<ClawHubSkillInstallResolutionResponse> {
|
||||
const { response, url, hasToken } = await clawhubRequest({
|
||||
baseUrl: params.baseUrl,
|
||||
path: `/api/v1/skills/${encodeURIComponent(params.slug)}/install`,
|
||||
token: params.token,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
search: {
|
||||
forceInstall: params.forceInstall ? "1" : undefined,
|
||||
},
|
||||
});
|
||||
const isStructuredBlock = [403, 409, 410, 423].includes(response.status);
|
||||
if (!response.ok && !isStructuredBlock) {
|
||||
throw await buildClawHubError(response, url, hasToken);
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as ClawHubSkillInstallResolutionResponse;
|
||||
} catch (cause) {
|
||||
throw new Error(`ClawHub ${url.pathname} returned malformed JSON`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClawHubSkillVerification(params: {
|
||||
slug: string;
|
||||
version?: string;
|
||||
@@ -1278,6 +1361,149 @@ export async function downloadClawHubSkillArchive(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadClawHubSkillArchiveUrl(params: {
|
||||
url: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<ClawHubDownloadResult> {
|
||||
const explicitToken = normalizeOptionalString(params.token);
|
||||
const requestUrl = new URL(params.url, `${normalizeBaseUrl(params.baseUrl)}/`);
|
||||
const registryOrigin = new URL(`${normalizeBaseUrl(params.baseUrl)}/`).origin;
|
||||
const skipAuth = explicitToken == null && requestUrl.origin !== registryOrigin;
|
||||
const { response, url, hasToken } = await clawhubRequest({
|
||||
baseUrl: params.baseUrl,
|
||||
url: params.url,
|
||||
token: explicitToken,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
skipAuth,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await buildClawHubError(response, url, hasToken);
|
||||
}
|
||||
const bytes = await readClawHubResponseBytes({
|
||||
response,
|
||||
timeoutMs: params.timeoutMs,
|
||||
resourceLabel: `skill archive download at ${url.pathname}`,
|
||||
});
|
||||
const sha256Hex = formatSha256Hex(bytes);
|
||||
const target = await createTempDownloadTarget({
|
||||
prefix: "openclaw-clawhub-skill",
|
||||
fileName: "skill.zip",
|
||||
tmpDir: os.tmpdir(),
|
||||
});
|
||||
await fs.writeFile(target.path, bytes);
|
||||
return {
|
||||
archivePath: target.path,
|
||||
integrity: formatSha256Integrity(bytes),
|
||||
sha256Hex,
|
||||
artifact: "archive",
|
||||
cleanup: target.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadClawHubGitHubSkillArchive(params: {
|
||||
repo: string;
|
||||
commit: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<ClawHubDownloadResult> {
|
||||
const downloadUrl = buildGitHubZipUrl(params.repo, params.commit);
|
||||
const { response, url, hasToken } = await clawhubRequest({
|
||||
url: downloadUrl,
|
||||
skipAuth: true,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await buildClawHubError(response, url, hasToken);
|
||||
}
|
||||
const bytes = await readClawHubResponseBytes({
|
||||
response,
|
||||
timeoutMs: params.timeoutMs,
|
||||
resourceLabel: `GitHub source archive for ${params.repo}@${params.commit}`,
|
||||
});
|
||||
const sha256Hex = formatSha256Hex(bytes);
|
||||
const target = await createTempDownloadTarget({
|
||||
prefix: "openclaw-clawhub-github-skill",
|
||||
fileName: `${params.commit}.zip`,
|
||||
tmpDir: os.tmpdir(),
|
||||
});
|
||||
await fs.writeFile(target.path, bytes);
|
||||
return {
|
||||
archivePath: target.path,
|
||||
integrity: formatSha256Integrity(bytes),
|
||||
sha256Hex,
|
||||
artifact: "archive",
|
||||
cleanup: target.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportClawHubSkillInstallTelemetry(params: {
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
root: string;
|
||||
skills: Record<string, ClawHubInstallTelemetrySkill>;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<void> {
|
||||
const token = normalizeOptionalString(params.token) ?? (await resolveClawHubAuthToken());
|
||||
if (!token || isClawHubTelemetryDisabled()) {
|
||||
return;
|
||||
}
|
||||
const skills = Object.entries(params.skills)
|
||||
.map(([slug, entry]) => ({
|
||||
slug,
|
||||
version: entry.version ?? null,
|
||||
}))
|
||||
.filter((entry) => entry.slug.length > 0);
|
||||
|
||||
const { response, url, hasToken } = await clawhubRequest({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/cli/telemetry/install",
|
||||
method: "POST",
|
||||
token,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchImpl: params.fetchImpl,
|
||||
json: {
|
||||
roots: [
|
||||
{
|
||||
rootId: createHash("sha256").update(path.resolve(params.root)).digest("hex"),
|
||||
label: formatTelemetryRootLabel(params.root),
|
||||
skills,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await buildClawHubError(response, url, hasToken);
|
||||
}
|
||||
}
|
||||
|
||||
function isClawHubTelemetryDisabled(): boolean {
|
||||
const raw = process.env.CLAWHUB_DISABLE_TELEMETRY ?? process.env.CLAWDHUB_DISABLE_TELEMETRY;
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function formatTelemetryRootLabel(root: string): string {
|
||||
const home = os.homedir();
|
||||
const absolute = path.resolve(root);
|
||||
if (absolute === home) {
|
||||
return "~";
|
||||
}
|
||||
const normalized = absolute.replaceAll("\\", "/");
|
||||
const normalizedHome = home.replaceAll("\\", "/");
|
||||
const withinHome = normalized.startsWith(`${normalizedHome}/`);
|
||||
const stripped = withinHome ? normalized.slice(normalizedHome.length + 1) : normalized;
|
||||
const tail = stripped.split("/").filter(Boolean).slice(-2).join("/");
|
||||
return withinHome ? `~/${tail}` : tail || absolute;
|
||||
}
|
||||
|
||||
/** Resolves the preferred latest package version from detail metadata. */
|
||||
export function resolveLatestVersionFromPackage(detail: ClawHubPackageDetail): string | null {
|
||||
return detail.package?.latestVersion ?? detail.package?.tags?.latest ?? null;
|
||||
|
||||
@@ -5,8 +5,12 @@ import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchClawHubSkillDetailMock = vi.fn();
|
||||
const fetchClawHubSkillInstallResolutionMock = vi.fn();
|
||||
const downloadClawHubSkillArchiveMock = vi.fn();
|
||||
const downloadClawHubSkillArchiveUrlMock = vi.fn();
|
||||
const downloadClawHubGitHubSkillArchiveMock = vi.fn();
|
||||
const listClawHubSkillsMock = vi.fn();
|
||||
const reportClawHubSkillInstallTelemetryMock = vi.fn();
|
||||
const resolveClawHubBaseUrlMock = vi.fn(() => "https://clawhub.ai");
|
||||
const isDefaultClawHubBaseUrlMock = vi.fn((baseUrl?: string) => !baseUrl);
|
||||
const searchClawHubSkillsMock = vi.fn();
|
||||
@@ -18,8 +22,12 @@ const pathExistsMock = vi.fn();
|
||||
|
||||
vi.mock("../../infra/clawhub.js", () => ({
|
||||
fetchClawHubSkillDetail: fetchClawHubSkillDetailMock,
|
||||
fetchClawHubSkillInstallResolution: fetchClawHubSkillInstallResolutionMock,
|
||||
downloadClawHubSkillArchive: downloadClawHubSkillArchiveMock,
|
||||
downloadClawHubSkillArchiveUrl: downloadClawHubSkillArchiveUrlMock,
|
||||
downloadClawHubGitHubSkillArchive: downloadClawHubGitHubSkillArchiveMock,
|
||||
listClawHubSkills: listClawHubSkillsMock,
|
||||
reportClawHubSkillInstallTelemetry: reportClawHubSkillInstallTelemetryMock,
|
||||
isDefaultClawHubBaseUrl: isDefaultClawHubBaseUrlMock,
|
||||
resolveClawHubBaseUrl: resolveClawHubBaseUrlMock,
|
||||
searchClawHubSkills: searchClawHubSkillsMock,
|
||||
@@ -156,8 +164,12 @@ async function writeClawHubOriginFixture(params: {
|
||||
describe("skills-clawhub", () => {
|
||||
beforeEach(() => {
|
||||
fetchClawHubSkillDetailMock.mockReset();
|
||||
fetchClawHubSkillInstallResolutionMock.mockReset();
|
||||
downloadClawHubSkillArchiveMock.mockReset();
|
||||
downloadClawHubSkillArchiveUrlMock.mockReset();
|
||||
downloadClawHubGitHubSkillArchiveMock.mockReset();
|
||||
listClawHubSkillsMock.mockReset();
|
||||
reportClawHubSkillInstallTelemetryMock.mockReset();
|
||||
resolveClawHubBaseUrlMock.mockReset();
|
||||
isDefaultClawHubBaseUrlMock.mockReset();
|
||||
searchClawHubSkillsMock.mockReset();
|
||||
@@ -184,11 +196,31 @@ describe("skills-clawhub", () => {
|
||||
createdAt: 3,
|
||||
},
|
||||
});
|
||||
fetchClawHubSkillInstallResolutionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
slug: "agentreceipt",
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.0.0",
|
||||
downloadUrl: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0",
|
||||
},
|
||||
});
|
||||
downloadClawHubSkillArchiveMock.mockResolvedValue({
|
||||
archivePath: "/tmp/agentreceipt.zip",
|
||||
integrity: "sha256-test",
|
||||
cleanup: archiveCleanupMock,
|
||||
});
|
||||
downloadClawHubSkillArchiveUrlMock.mockResolvedValue({
|
||||
archivePath: "/tmp/agentreceipt.zip",
|
||||
integrity: "sha256-test",
|
||||
cleanup: archiveCleanupMock,
|
||||
});
|
||||
downloadClawHubGitHubSkillArchiveMock.mockResolvedValue({
|
||||
archivePath: "/tmp/github-agentreceipt.zip",
|
||||
integrity: "sha256-github-test",
|
||||
cleanup: archiveCleanupMock,
|
||||
});
|
||||
reportClawHubSkillInstallTelemetryMock.mockResolvedValue(undefined);
|
||||
archiveCleanupMock.mockResolvedValue(undefined);
|
||||
searchClawHubSkillsMock.mockResolvedValue([]);
|
||||
withExtractedArchiveRootMock.mockImplementation(async (params) => {
|
||||
@@ -208,9 +240,12 @@ describe("skills-clawhub", () => {
|
||||
slug: "agentreceipt",
|
||||
});
|
||||
|
||||
expect(downloadClawHubSkillArchiveMock).toHaveBeenCalledWith({
|
||||
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
|
||||
slug: "agentreceipt",
|
||||
version: "1.0.0",
|
||||
baseUrl: undefined,
|
||||
});
|
||||
expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalledWith({
|
||||
url: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0",
|
||||
baseUrl: undefined,
|
||||
});
|
||||
expectInstallPackageSourceDir("/tmp/extracted-skill");
|
||||
@@ -224,6 +259,124 @@ describe("skills-clawhub", () => {
|
||||
targetDir: "/tmp/workspace/skills/agentreceipt",
|
||||
});
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
expect(reportClawHubSkillInstallTelemetryMock).toHaveBeenCalledWith({
|
||||
baseUrl: undefined,
|
||||
root: "/tmp/workspace",
|
||||
skills: expect.objectContaining({
|
||||
agentreceipt: {
|
||||
version: "1.0.0",
|
||||
installedAt: expect.any(Number),
|
||||
registry: "https://clawhub.ai",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("installs GitHub-backed ClawHub skills from the pinned resolver source path", async () => {
|
||||
const commit = "b".repeat(40);
|
||||
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
withExtractedArchiveRootMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.rootMarkers).toBeUndefined();
|
||||
return await params.onExtracted("/tmp/extracted-github-repo");
|
||||
});
|
||||
installPackageDirMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
targetDir: "/tmp/workspace/skills/aiq-deploy",
|
||||
});
|
||||
|
||||
const result = await installSkillFromClawHub({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
slug: "aiq-deploy",
|
||||
});
|
||||
|
||||
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
|
||||
slug: "aiq-deploy",
|
||||
baseUrl: undefined,
|
||||
});
|
||||
expect(downloadClawHubGitHubSkillArchiveMock).toHaveBeenCalledWith({
|
||||
repo: "NVIDIA/skills",
|
||||
commit,
|
||||
});
|
||||
expectInstallPackageSourceDir("/tmp/extracted-github-repo/skills/aiq-deploy");
|
||||
expect(installPolicyInput()).toMatchObject({
|
||||
origin: {
|
||||
registry: "https://clawhub.ai",
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
},
|
||||
source: { kind: "git", authority: "third-party", mutable: false, network: true },
|
||||
});
|
||||
expectInstalledSkill(result, {
|
||||
slug: "aiq-deploy",
|
||||
version: commit,
|
||||
targetDir: "/tmp/workspace/skills/aiq-deploy",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes forceInstall to the ClawHub install resolver", async () => {
|
||||
const commit = "b".repeat(40);
|
||||
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
withExtractedArchiveRootMock.mockImplementationOnce(async (params) => {
|
||||
return await params.onExtracted("/tmp/extracted-github-repo");
|
||||
});
|
||||
installPackageDirMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
targetDir: "/tmp/workspace/skills/aiq-deploy",
|
||||
});
|
||||
|
||||
const result = await installSkillFromClawHub({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
slug: "aiq-deploy",
|
||||
forceInstall: true,
|
||||
});
|
||||
|
||||
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
|
||||
slug: "aiq-deploy",
|
||||
baseUrl: undefined,
|
||||
forceInstall: true,
|
||||
});
|
||||
expectInstalledSkill(result, {
|
||||
slug: "aiq-deploy",
|
||||
version: commit,
|
||||
targetDir: "/tmp/workspace/skills/aiq-deploy",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps ClawHub install telemetry best-effort", async () => {
|
||||
reportClawHubSkillInstallTelemetryMock.mockRejectedValueOnce(new Error("telemetry down"));
|
||||
|
||||
const result = await installSkillFromClawHub({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
slug: "agentreceipt",
|
||||
});
|
||||
|
||||
expectInstalledSkill(result, {
|
||||
slug: "agentreceipt",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks custom ClawHub skill registries as third-party install policy authority", async () => {
|
||||
@@ -312,6 +465,15 @@ describe("skills-clawhub", () => {
|
||||
it("updates all tracked legacy Unicode slugs in place", async () => {
|
||||
const slug = "re\u0430ct";
|
||||
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
|
||||
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug,
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.0.0",
|
||||
downloadUrl: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
|
||||
},
|
||||
});
|
||||
installPackageDirMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
targetDir: path.join(workspaceDir, "skills", slug),
|
||||
@@ -322,13 +484,12 @@ describe("skills-clawhub", () => {
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({
|
||||
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
|
||||
slug,
|
||||
baseUrl: "https://legacy.clawhub.ai",
|
||||
});
|
||||
expect(downloadClawHubSkillArchiveMock).toHaveBeenCalledWith({
|
||||
slug,
|
||||
version: "1.0.0",
|
||||
expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalledWith({
|
||||
url: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
|
||||
baseUrl: "https://legacy.clawhub.ai",
|
||||
});
|
||||
expectLegacyUpdateSuccess(results, workspaceDir, slug);
|
||||
@@ -337,6 +498,40 @@ describe("skills-clawhub", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes forceInstall to resolver for tracked updates", async () => {
|
||||
const slug = "agentreceipt";
|
||||
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
|
||||
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug,
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.0.0",
|
||||
downloadUrl: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
|
||||
},
|
||||
});
|
||||
installPackageDirMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
targetDir: path.join(workspaceDir, "skills", slug),
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await updateSkillsFromClawHub({
|
||||
workspaceDir,
|
||||
forceInstall: true,
|
||||
});
|
||||
|
||||
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
|
||||
slug,
|
||||
baseUrl: "https://legacy.clawhub.ai",
|
||||
forceInstall: true,
|
||||
});
|
||||
expectLegacyUpdateSuccess(results, workspaceDir, slug);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates a legacy Unicode slug when requested explicitly", async () => {
|
||||
const slug = "re\u0430ct";
|
||||
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
|
||||
|
||||
@@ -3,12 +3,17 @@ import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
downloadClawHubGitHubSkillArchive,
|
||||
downloadClawHubSkillArchive,
|
||||
downloadClawHubSkillArchiveUrl,
|
||||
fetchClawHubSkillDetail,
|
||||
fetchClawHubSkillInstallResolution,
|
||||
isDefaultClawHubBaseUrl,
|
||||
reportClawHubSkillInstallTelemetry,
|
||||
resolveClawHubBaseUrl,
|
||||
searchClawHubSkills,
|
||||
type ClawHubSkillDetail,
|
||||
type ClawHubSkillInstallResolutionResponse,
|
||||
type ClawHubSkillSearchResult,
|
||||
} from "../../infra/clawhub.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
@@ -93,7 +98,7 @@ export type InstallClawHubSkillResult =
|
||||
slug: string;
|
||||
version: string;
|
||||
targetDir: string;
|
||||
detail: ClawHubSkillDetail;
|
||||
detail?: ClawHubSkillDetail;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
@@ -132,6 +137,7 @@ type ClawHubInstallParams = {
|
||||
version?: string;
|
||||
baseUrl?: string;
|
||||
force?: boolean;
|
||||
forceInstall?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
};
|
||||
@@ -756,15 +762,125 @@ async function resolveInstallVersion(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitHubSourcePath(raw: string): string {
|
||||
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error(`Invalid GitHub skill source path: ${raw}`);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function resolveGitHubSkillSourceDir(repoRoot: string, sourcePath: string): string {
|
||||
const normalized = normalizeGitHubSourcePath(sourcePath);
|
||||
return path.join(repoRoot, ...normalized.split("/"));
|
||||
}
|
||||
|
||||
async function installArchiveResolution(params: {
|
||||
workspaceDir: string;
|
||||
slug: string;
|
||||
version: string;
|
||||
archivePath: string;
|
||||
registry: string;
|
||||
authority: "openclaw" | "third-party";
|
||||
force?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
return await withExtractedArchiveRoot({
|
||||
archivePath: params.archivePath,
|
||||
tempDirPrefix: "openclaw-skill-clawhub-",
|
||||
timeoutMs: 120_000,
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
onExtracted: async (rootDir) =>
|
||||
await installExtractedSkillRoot({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
extractedRoot: rootDir,
|
||||
mode: params.force ? "update" : "install",
|
||||
logger: params.logger,
|
||||
policy: {
|
||||
config: params.config,
|
||||
installId: "clawhub",
|
||||
origin: {
|
||||
type: "clawhub",
|
||||
registry: params.registry,
|
||||
slug: params.slug,
|
||||
version: params.version,
|
||||
},
|
||||
source: {
|
||||
kind: "clawhub",
|
||||
authority: params.authority,
|
||||
mutable: false,
|
||||
network: true,
|
||||
},
|
||||
requestedSpecifier: `clawhub:${params.slug}@${params.version}`,
|
||||
},
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function installGitHubResolution(params: {
|
||||
workspaceDir: string;
|
||||
slug: string;
|
||||
sourcePath: string;
|
||||
archivePath: string;
|
||||
registry: string;
|
||||
repo: string;
|
||||
commit: string;
|
||||
force?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
return await withExtractedArchiveRoot({
|
||||
archivePath: params.archivePath,
|
||||
tempDirPrefix: "openclaw-skill-clawhub-github-",
|
||||
timeoutMs: 120_000,
|
||||
onExtracted: async (repoRoot) =>
|
||||
await installExtractedSkillRoot({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
extractedRoot: resolveGitHubSkillSourceDir(repoRoot, params.sourcePath),
|
||||
mode: params.force ? "update" : "install",
|
||||
logger: params.logger,
|
||||
policy: {
|
||||
config: params.config,
|
||||
installId: "clawhub",
|
||||
origin: {
|
||||
type: "clawhub",
|
||||
registry: params.registry,
|
||||
slug: params.slug,
|
||||
version: params.commit,
|
||||
repo: params.repo,
|
||||
path: params.sourcePath,
|
||||
commit: params.commit,
|
||||
},
|
||||
source: {
|
||||
kind: "git",
|
||||
authority: "third-party",
|
||||
mutable: false,
|
||||
network: true,
|
||||
},
|
||||
requestedSpecifier: `clawhub:${params.slug}@${params.commit}`,
|
||||
},
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function assertInstallResolutionAllowed(
|
||||
resolution: ClawHubSkillInstallResolutionResponse,
|
||||
): Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> {
|
||||
if (resolution.ok) {
|
||||
return resolution;
|
||||
}
|
||||
throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`);
|
||||
}
|
||||
|
||||
async function performClawHubSkillInstall(
|
||||
params: ClawHubInstallParams,
|
||||
): Promise<InstallClawHubSkillResult> {
|
||||
try {
|
||||
const { detail, version } = await resolveInstallVersion({
|
||||
slug: params.slug,
|
||||
version: params.version,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug);
|
||||
const registry = resolveClawHubBaseUrl(params.baseUrl);
|
||||
const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party";
|
||||
@@ -775,45 +891,93 @@ async function performClawHubSkillInstall(
|
||||
};
|
||||
}
|
||||
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
|
||||
const archive = await downloadClawHubSkillArchive({
|
||||
slug: params.slug,
|
||||
version,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
try {
|
||||
const install = await withExtractedArchiveRoot({
|
||||
archivePath: archive.archivePath,
|
||||
tempDirPrefix: "openclaw-skill-clawhub-",
|
||||
timeoutMs: 120_000,
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
onExtracted: async (rootDir) =>
|
||||
await installExtractedSkillRoot({
|
||||
workspaceDir: params.workspaceDir,
|
||||
let version!: string;
|
||||
let detail: ClawHubSkillDetail | undefined;
|
||||
let latestResolution: Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> | undefined;
|
||||
let install: Awaited<ReturnType<typeof installArchiveResolution>>;
|
||||
|
||||
const archive = params.version
|
||||
? await (async () => {
|
||||
const resolved = await resolveInstallVersion({
|
||||
slug: params.slug,
|
||||
extractedRoot: rootDir,
|
||||
mode: params.force ? "update" : "install",
|
||||
logger: params.logger,
|
||||
policy: {
|
||||
config: params.config,
|
||||
installId: "clawhub",
|
||||
origin: {
|
||||
type: "clawhub",
|
||||
version: params.version,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
detail = resolved.detail;
|
||||
version = resolved.version;
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
|
||||
return await downloadClawHubSkillArchive({
|
||||
slug: params.slug,
|
||||
version,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
})()
|
||||
: await (async () => {
|
||||
latestResolution = assertInstallResolutionAllowed(
|
||||
await fetchClawHubSkillInstallResolution({
|
||||
slug: params.slug,
|
||||
baseUrl: params.baseUrl,
|
||||
forceInstall: params.forceInstall,
|
||||
}),
|
||||
);
|
||||
if (latestResolution.installKind === "github") {
|
||||
version = latestResolution.github.commit;
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`);
|
||||
return await downloadClawHubGitHubSkillArchive({
|
||||
repo: latestResolution.github.repo,
|
||||
commit: latestResolution.github.commit,
|
||||
});
|
||||
}
|
||||
version = latestResolution.archive.version;
|
||||
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
|
||||
return await downloadClawHubSkillArchiveUrl({
|
||||
url: latestResolution.archive.downloadUrl,
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
})();
|
||||
try {
|
||||
if (!params.version) {
|
||||
if (!latestResolution) {
|
||||
throw new Error(`Skill "${params.slug}" has no install resolution.`);
|
||||
}
|
||||
install =
|
||||
latestResolution.installKind === "github"
|
||||
? await installGitHubResolution({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
sourcePath: latestResolution.github.path,
|
||||
archivePath: archive.archivePath,
|
||||
registry,
|
||||
repo: latestResolution.github.repo,
|
||||
commit: latestResolution.github.commit,
|
||||
force: params.force,
|
||||
logger: params.logger,
|
||||
config: params.config,
|
||||
})
|
||||
: await installArchiveResolution({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
version,
|
||||
},
|
||||
source: {
|
||||
kind: "clawhub",
|
||||
archivePath: archive.archivePath,
|
||||
registry,
|
||||
authority: clawhubAuthority,
|
||||
mutable: false,
|
||||
network: true,
|
||||
},
|
||||
requestedSpecifier: `clawhub:${params.slug}@${version}`,
|
||||
},
|
||||
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
|
||||
}),
|
||||
});
|
||||
force: params.force,
|
||||
logger: params.logger,
|
||||
config: params.config,
|
||||
});
|
||||
} else {
|
||||
install = await installArchiveResolution({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: params.slug,
|
||||
version,
|
||||
archivePath: archive.archivePath,
|
||||
registry,
|
||||
authority: clawhubAuthority,
|
||||
force: params.force,
|
||||
logger: params.logger,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
if (!install.ok) {
|
||||
return { ok: false, error: install.error };
|
||||
}
|
||||
@@ -833,13 +997,18 @@ async function performClawHubSkillInstall(
|
||||
registry: resolveClawHubBaseUrl(params.baseUrl),
|
||||
};
|
||||
await writeClawHubSkillsLockfile(params.workspaceDir, lock);
|
||||
await reportClawHubSkillInstallTelemetry({
|
||||
baseUrl: params.baseUrl,
|
||||
root: params.workspaceDir,
|
||||
skills: lock.skills,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
slug: params.slug,
|
||||
version,
|
||||
targetDir: install.targetDir,
|
||||
detail,
|
||||
...(detail ? { detail } : {}),
|
||||
};
|
||||
} finally {
|
||||
await archive.cleanup().catch(() => undefined);
|
||||
@@ -913,6 +1082,7 @@ export async function installSkillFromClawHub(params: {
|
||||
version?: string;
|
||||
baseUrl?: string;
|
||||
force?: boolean;
|
||||
forceInstall?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<InstallClawHubSkillResult> {
|
||||
@@ -923,6 +1093,7 @@ export async function updateSkillsFromClawHub(params: {
|
||||
workspaceDir: string;
|
||||
slug?: string;
|
||||
baseUrl?: string;
|
||||
forceInstall?: boolean;
|
||||
logger?: Logger;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<UpdateClawHubSkillResult[]> {
|
||||
@@ -956,6 +1127,7 @@ export async function updateSkillsFromClawHub(params: {
|
||||
slug: tracked.slug,
|
||||
baseUrl: tracked.baseUrl,
|
||||
force: true,
|
||||
forceInstall: params.forceInstall,
|
||||
logger: params.logger,
|
||||
config: params.config,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user