diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4de633de..0ce8754efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline. - Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh. - Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable. +- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories. - Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. - BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. - Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 2e6b806619..f17865f6b7 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -103,6 +103,8 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw openclaw hooks install ``` +Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected. + Example `package.json`: ```json @@ -118,6 +120,10 @@ Example `package.json`: Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`). Hook packs can ship dependencies; they will be installed under `~/.openclaw/hooks/`. +Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts` +(no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely +on `postinstall` builds. + ## Hook Structure ### HOOK.md Format diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 64f16f3de1..a676a709ac 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -192,6 +192,9 @@ openclaw hooks install Install a hook pack from a local folder/archive or npm. +Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file +specs are rejected. Dependency installs run with `--ignore-scripts` for safety. + **What it does:** - Copies the hook pack into `~/.openclaw/hooks/` diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0dc21fc7af..cc7eeb18f9 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -44,6 +44,9 @@ openclaw plugins install Security note: treat plugin installs like running code. Prefer pinned versions. +Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file +specs are rejected. Dependency installs run with `--ignore-scripts` for safety. + Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 50d4ffd777..bbd0fb4bcd 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -31,6 +31,9 @@ openclaw plugins list openclaw plugins install @openclaw/voice-call ``` +Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file +specs are rejected. + 3. Restart the Gateway, then configure under `plugins.entries..config`. See [Voice Call](/plugins/voice-call) for a concrete example plugin. @@ -138,6 +141,10 @@ becomes `name/`. If your plugin imports npm deps, install them in that directory so `node_modules` is available (`npm install` / `pnpm install`). +Security note: `openclaw plugins install` installs plugin dependencies with +`npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency +trees "pure JS/TS" and avoid packages that require `postinstall` builds. + ### Channel catalog metadata Channel plugins can advertise onboarding metadata via `openclaw.channel` and @@ -424,7 +431,7 @@ Notes: ### Write a new messaging channel (step‑by‑step) -Use this when you want a **new chat surface** (a “messaging channel”), not a model provider. +Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. Model provider docs live under `/providers/*`. 1. Pick an id + config shape diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index 0bbfc5bb6c..7c15586bf5 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import * as tar from "tar"; -import { afterAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; const fixtureRoot = path.join(os.tmpdir(), `openclaw-hook-install-${randomUUID()}`); let tempDirIndex = 0; @@ -13,6 +13,28 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: vi.fn(), })); +async function packToArchive({ + pkgDir, + outDir, + outName, +}: { + pkgDir: string; + outDir: string; + outName: string; +}) { + const dest = path.join(outDir, outName); + fs.rmSync(dest, { force: true }); + await tar.c( + { + gzip: true, + file: dest, + cwd: path.dirname(pkgDir), + }, + [path.basename(pkgDir)], + ); + return dest; +} + function makeTempDir() { const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`); fs.mkdirSync(dir, { recursive: true }); @@ -20,7 +42,8 @@ function makeTempDir() { } const { runCommandWithTimeout } = await import("../process/exec.js"); -const { installHooksFromArchive, installHooksFromPath } = await import("./install.js"); +const { installHooksFromArchive, installHooksFromNpmSpec, installHooksFromPath } = + await import("./install.js"); afterAll(() => { try { @@ -30,6 +53,10 @@ afterAll(() => { } }); +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("installHooksFromArchive", () => { it("installs hook packs from zip archives", async () => { const stateDir = makeTempDir(); @@ -308,3 +335,88 @@ describe("installHooksFromPath", () => { expect(fs.existsSync(path.join(result.targetDir, "HOOK.md"))).toBe(true); }); }); + +describe("installHooksFromNpmSpec", () => { + it("uses --ignore-scripts for npm pack and cleans up temp dir", async () => { + const workDir = makeTempDir(); + const stateDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "hooks", "one-hook"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@openclaw/test-hooks", + version: "0.0.1", + openclaw: { hooks: ["./hooks/one-hook"] }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "hooks", "one-hook", "HOOK.md"), + [ + "---", + "name: one-hook", + "description: One hook", + 'metadata: {"openclaw":{"events":["command:new"]}}', + "---", + "", + "# One Hook", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "hooks", "one-hook", "handler.ts"), + "export default async () => {};\n", + "utf-8", + ); + + const run = vi.mocked(runCommandWithTimeout); + let packTmpDir = ""; + const packedName = "test-hooks-0.0.1.tgz"; + run.mockImplementation(async (argv, opts) => { + if (argv[0] === "npm" && argv[1] === "pack") { + packTmpDir = String(opts?.cwd ?? ""); + await packToArchive({ pkgDir, outDir: packTmpDir, outName: packedName }); + return { code: 0, stdout: `${packedName}\n`, stderr: "", signal: null, killed: false }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }); + + const hooksDir = path.join(stateDir, "hooks"); + const result = await installHooksFromNpmSpec({ + spec: "@openclaw/test-hooks@0.0.1", + hooksDir, + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.hookPackId).toBe("test-hooks"); + expect(fs.existsSync(path.join(result.targetDir, "hooks", "one-hook", "HOOK.md"))).toBe(true); + + const packCalls = run.mock.calls.filter( + (c) => Array.isArray(c[0]) && c[0][0] === "npm" && c[0][1] === "pack", + ); + expect(packCalls.length).toBe(1); + const packCall = packCalls[0]; + if (!packCall) { + throw new Error("expected npm pack call"); + } + const [argv, options] = packCall; + expect(argv).toEqual(["npm", "pack", "@openclaw/test-hooks@0.0.1", "--ignore-scripts"]); + expect(options?.env).toMatchObject({ NPM_CONFIG_IGNORE_SCRIPTS: "true" }); + + expect(packTmpDir).not.toBe(""); + expect(fs.existsSync(packTmpDir)).toBe(false); + }); + + it("rejects non-registry npm specs", async () => { + const result = await installHooksFromNpmSpec({ spec: "github:evil/evil" }); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("unsupported npm spec"); + }); +}); diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 1d3dbe8c6c..a351bd79ef 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -9,6 +9,7 @@ import { resolveArchiveKind, resolvePackedRootDir, } from "../infra/archive.js"; +import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { parseFrontmatter } from "./frontmatter.js"; @@ -356,44 +357,48 @@ export async function installHooksFromArchive(params: { } const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-")); - const extractDir = path.join(tmpDir, "extract"); - await fs.mkdir(extractDir, { recursive: true }); - - logger.info?.(`Extracting ${archivePath}…`); try { - await extractArchive({ archivePath, destDir: extractDir, timeoutMs, logger }); - } catch (err) { - return { ok: false, error: `failed to extract archive: ${String(err)}` }; - } + const extractDir = path.join(tmpDir, "extract"); + await fs.mkdir(extractDir, { recursive: true }); - let rootDir = ""; - try { - rootDir = await resolvePackedRootDir(extractDir); - } catch (err) { - return { ok: false, error: String(err) }; - } + logger.info?.(`Extracting ${archivePath}…`); + try { + await extractArchive({ archivePath, destDir: extractDir, timeoutMs, logger }); + } catch (err) { + return { ok: false, error: `failed to extract archive: ${String(err)}` }; + } - const manifestPath = path.join(rootDir, "package.json"); - if (await fileExists(manifestPath)) { - return await installHookPackageFromDir({ - packageDir: rootDir, + let rootDir = ""; + try { + rootDir = await resolvePackedRootDir(extractDir); + } catch (err) { + return { ok: false, error: String(err) }; + } + + const manifestPath = path.join(rootDir, "package.json"); + if (await fileExists(manifestPath)) { + return await installHookPackageFromDir({ + packageDir: rootDir, + hooksDir: params.hooksDir, + timeoutMs, + logger, + mode: params.mode, + dryRun: params.dryRun, + expectedHookPackId: params.expectedHookPackId, + }); + } + + return await installHookFromDir({ + hookDir: rootDir, hooksDir: params.hooksDir, - timeoutMs, logger, mode: params.mode, dryRun: params.dryRun, expectedHookPackId: params.expectedHookPackId, }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); } - - return await installHookFromDir({ - hookDir: rootDir, - hooksDir: params.hooksDir, - logger, - mode: params.mode, - dryRun: params.dryRun, - expectedHookPackId: params.expectedHookPackId, - }); } export async function installHooksFromNpmSpec(params: { @@ -411,40 +416,48 @@ export async function installHooksFromNpmSpec(params: { const dryRun = params.dryRun ?? false; const expectedHookPackId = params.expectedHookPackId; const spec = params.spec.trim(); - if (!spec) { - return { ok: false, error: "missing npm spec" }; + const specError = validateRegistryNpmSpec(spec); + if (specError) { + return { ok: false, error: specError }; } const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-pack-")); - logger.info?.(`Downloading ${spec}…`); - const res = await runCommandWithTimeout(["npm", "pack", spec], { - timeoutMs: Math.max(timeoutMs, 300_000), - cwd: tmpDir, - env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, - }); - if (res.code !== 0) { - return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` }; - } + try { + logger.info?.(`Downloading ${spec}…`); + const res = await runCommandWithTimeout(["npm", "pack", spec, "--ignore-scripts"], { + timeoutMs: Math.max(timeoutMs, 300_000), + cwd: tmpDir, + env: { + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }, + }); + if (res.code !== 0) { + return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` }; + } - const packed = (res.stdout || "") - .split("\n") - .map((l) => l.trim()) - .filter(Boolean) - .pop(); - if (!packed) { - return { ok: false, error: "npm pack produced no archive" }; - } + const packed = (res.stdout || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .pop(); + if (!packed) { + return { ok: false, error: "npm pack produced no archive" }; + } - const archivePath = path.join(tmpDir, packed); - return await installHooksFromArchive({ - archivePath, - hooksDir: params.hooksDir, - timeoutMs, - logger, - mode, - dryRun, - expectedHookPackId, - }); + const archivePath = path.join(tmpDir, packed); + return await installHooksFromArchive({ + archivePath, + hooksDir: params.hooksDir, + timeoutMs, + logger, + mode, + dryRun, + expectedHookPackId, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + } } export async function installHooksFromPath(params: { diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts new file mode 100644 index 0000000000..5861d30171 --- /dev/null +++ b/src/infra/npm-registry-spec.ts @@ -0,0 +1,41 @@ +export function validateRegistryNpmSpec(rawSpec: string): string | null { + const spec = rawSpec.trim(); + if (!spec) { + return "missing npm spec"; + } + if (/\s/.test(spec)) { + return "unsupported npm spec: whitespace is not allowed"; + } + // Registry-only: no URLs, git, file, or alias protocols. + // Keep strict: this runs on the gateway host. + if (spec.includes("://")) { + return "unsupported npm spec: URLs are not allowed"; + } + if (spec.includes("#")) { + return "unsupported npm spec: git refs are not allowed"; + } + if (spec.includes(":")) { + return "unsupported npm spec: protocol specs are not allowed"; + } + + const at = spec.lastIndexOf("@"); + const hasVersion = at > 0; + const name = hasVersion ? spec.slice(0, at) : spec; + const version = hasVersion ? spec.slice(at + 1) : ""; + + const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/; + const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/; + const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name); + if (!isValidName) { + return "unsupported npm spec: expected or @ from the npm registry"; + } + if (hasVersion) { + if (!version) { + return "unsupported npm spec: missing version/tag after @"; + } + if (/[\\/]/.test(version)) { + return "unsupported npm spec: invalid version/tag"; + } + } + return null; +} diff --git a/src/plugins/install.e2e.test.ts b/src/plugins/install.e2e.test.ts index c603dd9d97..eb7e15ffd0 100644 --- a/src/plugins/install.e2e.test.ts +++ b/src/plugins/install.e2e.test.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import * as tar from "tar"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as skillScanner from "../security/skill-scanner.js"; vi.mock("../process/exec.js", () => ({ @@ -52,6 +52,10 @@ afterEach(() => { } }); +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("installPluginFromArchive", () => { it("installs into ~/.openclaw/extensions and uses unscoped id", async () => { const stateDir = makeTempDir(); @@ -487,3 +491,72 @@ describe("installPluginFromDir", () => { expect(opts?.cwd).toBe(res.targetDir); }); }); + +describe("installPluginFromNpmSpec", () => { + it("uses --ignore-scripts for npm pack and cleans up temp dir", async () => { + const workDir = makeTempDir(); + const stateDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@openclaw/voice-call", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); + + const extensionsDir = path.join(stateDir, "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + + const { runCommandWithTimeout } = await import("../process/exec.js"); + const run = vi.mocked(runCommandWithTimeout); + + let packTmpDir = ""; + const packedName = "voice-call-0.0.1.tgz"; + run.mockImplementation(async (argv, opts) => { + if (argv[0] === "npm" && argv[1] === "pack") { + packTmpDir = String(opts?.cwd ?? ""); + await packToArchive({ pkgDir, outDir: packTmpDir, outName: packedName }); + return { code: 0, stdout: `${packedName}\n`, stderr: "", signal: null, killed: false }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }); + + const { installPluginFromNpmSpec } = await import("./install.js"); + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@0.0.1", + extensionsDir, + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(true); + + const packCalls = run.mock.calls.filter( + (c) => Array.isArray(c[0]) && c[0][0] === "npm" && c[0][1] === "pack", + ); + expect(packCalls.length).toBe(1); + const packCall = packCalls[0]; + if (!packCall) { + throw new Error("expected npm pack call"); + } + const [argv, options] = packCall; + expect(argv).toEqual(["npm", "pack", "@openclaw/voice-call@0.0.1", "--ignore-scripts"]); + expect(options?.env).toMatchObject({ NPM_CONFIG_IGNORE_SCRIPTS: "true" }); + + expect(packTmpDir).not.toBe(""); + expect(fs.existsSync(packTmpDir)).toBe(false); + }); + + it("rejects non-registry npm specs", async () => { + const { installPluginFromNpmSpec } = await import("./install.js"); + const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" }); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error).toContain("unsupported npm spec"); + }); +}); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 6d661d97e8..fcd5867bf9 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -9,6 +9,7 @@ import { resolveArchiveKind, resolvePackedRootDir, } from "../infra/archive.js"; +import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { runCommandWithTimeout } from "../process/exec.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; @@ -334,37 +335,41 @@ export async function installPluginFromArchive(params: { } const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-")); - const extractDir = path.join(tmpDir, "extract"); - await fs.mkdir(extractDir, { recursive: true }); - - logger.info?.(`Extracting ${archivePath}…`); try { - await extractArchive({ - archivePath, - destDir: extractDir, + const extractDir = path.join(tmpDir, "extract"); + await fs.mkdir(extractDir, { recursive: true }); + + logger.info?.(`Extracting ${archivePath}…`); + try { + await extractArchive({ + archivePath, + destDir: extractDir, + timeoutMs, + logger, + }); + } catch (err) { + return { ok: false, error: `failed to extract archive: ${String(err)}` }; + } + + let packageDir = ""; + try { + packageDir = await resolvePackedRootDir(extractDir); + } catch (err) { + return { ok: false, error: String(err) }; + } + + return await installPluginFromPackageDir({ + packageDir, + extensionsDir: params.extensionsDir, timeoutMs, logger, + mode, + dryRun: params.dryRun, + expectedPluginId: params.expectedPluginId, }); - } catch (err) { - return { ok: false, error: `failed to extract archive: ${String(err)}` }; + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); } - - let packageDir = ""; - try { - packageDir = await resolvePackedRootDir(extractDir); - } catch (err) { - return { ok: false, error: String(err) }; - } - - return await installPluginFromPackageDir({ - packageDir, - extensionsDir: params.extensionsDir, - timeoutMs, - logger, - mode, - dryRun: params.dryRun, - expectedPluginId: params.expectedPluginId, - }); } export async function installPluginFromDir(params: { @@ -468,43 +473,51 @@ export async function installPluginFromNpmSpec(params: { const dryRun = params.dryRun ?? false; const expectedPluginId = params.expectedPluginId; const spec = params.spec.trim(); - if (!spec) { - return { ok: false, error: "missing npm spec" }; + const specError = validateRegistryNpmSpec(spec); + if (specError) { + return { ok: false, error: specError }; } const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-pack-")); - logger.info?.(`Downloading ${spec}…`); - const res = await runCommandWithTimeout(["npm", "pack", spec], { - timeoutMs: Math.max(timeoutMs, 300_000), - cwd: tmpDir, - env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, - }); - if (res.code !== 0) { - return { - ok: false, - error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}`, - }; - } + try { + logger.info?.(`Downloading ${spec}…`); + const res = await runCommandWithTimeout(["npm", "pack", spec, "--ignore-scripts"], { + timeoutMs: Math.max(timeoutMs, 300_000), + cwd: tmpDir, + env: { + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }, + }); + if (res.code !== 0) { + return { + ok: false, + error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}`, + }; + } - const packed = (res.stdout || "") - .split("\n") - .map((l) => l.trim()) - .filter(Boolean) - .pop(); - if (!packed) { - return { ok: false, error: "npm pack produced no archive" }; - } + const packed = (res.stdout || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .pop(); + if (!packed) { + return { ok: false, error: "npm pack produced no archive" }; + } - const archivePath = path.join(tmpDir, packed); - return await installPluginFromArchive({ - archivePath, - extensionsDir: params.extensionsDir, - timeoutMs, - logger, - mode, - dryRun, - expectedPluginId, - }); + const archivePath = path.join(tmpDir, packed); + return await installPluginFromArchive({ + archivePath, + extensionsDir: params.extensionsDir, + timeoutMs, + logger, + mode, + dryRun, + expectedPluginId, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + } } export async function installPluginFromPath(params: {