diff --git a/scripts/e2e/lib/codex-on-demand/assertions.mjs b/scripts/e2e/lib/codex-on-demand/assertions.mjs index dc49264f3312..21b41487e517 100644 --- a/scripts/e2e/lib/codex-on-demand/assertions.mjs +++ b/scripts/e2e/lib/codex-on-demand/assertions.mjs @@ -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(); diff --git a/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs b/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs index 4be88fdff9af..6f3975aa1192 100644 --- a/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs +++ b/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs @@ -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"]) { diff --git a/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs b/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs index d3996554a884..3aece08a3182 100644 --- a/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs +++ b/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs @@ -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) { diff --git a/scripts/ensure-playwright-chromium.mjs b/scripts/ensure-playwright-chromium.mjs index 2faae2385d16..0490698adcd0 100644 --- a/scripts/ensure-playwright-chromium.mjs +++ b/scripts/ensure-playwright-chromium.mjs @@ -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.`, ); diff --git a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts index b1a5f06a6bb7..e60386936011 100644 --- a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts +++ b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts @@ -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((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((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, + ); }); diff --git a/src/auto-reply/heartbeat.test.ts b/src/auto-reply/heartbeat.test.ts index ea2399edea94..fbb53d954a9f 100644 --- a/src/auto-reply/heartbeat.test.ts +++ b/src/auto-reply/heartbeat.test.ts @@ -216,6 +216,16 @@ tasks: expect(isHeartbeatContentEffectivelyEmpty("Reminder ")).toBe(false); }); + it("returns true for HTML comments only", () => { + expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true); + expect( + isHeartbeatContentEffectivelyEmpty(` + +# HEARTBEAT.md +`), + ).toBe(true); + }); + it("returns false when a template includes plain instructional prose", () => { const defaultTemplate = `# HEARTBEAT.md diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 7262cb77f31a..9f9e08e5e2f2 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -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 #) diff --git a/src/cli/skills-cli.clawhub-install.e2e.test.ts b/src/cli/skills-cli.clawhub-install.e2e.test.ts new file mode 100644 index 000000000000..71b13f34af13 --- /dev/null +++ b/src/cli/skills-cli.clawhub-install.e2e.test.ts @@ -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 { + 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 { + 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 { + 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((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((resolve) => { + server.close(() => { + resolve(); + }); + }); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 0fba01f34bd7..880f37d491ee 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -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"); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 7cb9922a7191..d5d568309971 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -287,6 +287,11 @@ export function registerSkillsCli(program: Command) { .argument("", "ClawHub skill slug, git:, or local skill directory") .option("--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 ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .option("--as ", "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 ", "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), }, diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index aad6dbc85cf2..e0a8df62d75e 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -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); + } + }); }); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index eff6362dbb35..cdcfb1f4514d 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -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; }; +export type ClawHubInstallTelemetrySkill = { + version?: string | null; +}; + type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; 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 { + 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 { + 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 { + 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; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + 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; diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index 89edac091296..44afff6cdad4 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -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); diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index 8a61f2885273..29c1c37245ca 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -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 { + if (resolution.ok) { + return resolution; + } + throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`); +} + async function performClawHubSkillInstall( params: ClawHubInstallParams, ): Promise { 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 | undefined; + let install: Awaited>; + + 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 { @@ -923,6 +1093,7 @@ export async function updateSkillsFromClawHub(params: { workspaceDir: string; slug?: string; baseUrl?: string; + forceInstall?: boolean; logger?: Logger; config?: OpenClawConfig; }): Promise { @@ -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, });