// Covers plugin install behavior from git-backed sources. import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DiagnosticSecurityEvent } from "../infra/diagnostic-events.js"; const runCommandWithTimeoutMock = vi.fn(); const installPluginFromInstalledPackageDirMock = vi.fn(); const preflightPluginGitInstallPolicyMock = vi.fn(); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); vi.mock("./install.js", async () => { const actual = await vi.importActual("./install.js"); return { ...actual, installPluginFromInstalledPackageDir: (...args: unknown[]) => installPluginFromInstalledPackageDirMock(...args), }; }); vi.mock("./install-security-scan.js", async () => { const actual = await vi.importActual( "./install-security-scan.js", ); return { ...actual, preflightPluginGitInstallPolicy: (...args: unknown[]) => preflightPluginGitInstallPolicyMock(...args), }; }); vi.resetModules(); const { installPluginFromGitSpec, isImmutableGitCommitRef, parseGitPluginSpec } = await import("./git-install.js"); const { onInternalDiagnosticEvent } = await import("../infra/diagnostic-events.js"); function expectedGitRepoDir(params: { gitDir: string; normalizedSpec: string }): string { const hash = createHash("sha256") .update(redactSensitiveUrlLikeString(params.normalizedSpec)) .digest("hex") .slice(0, 16); return path.join(params.gitDir, `git-${hash}`, "repo"); } function expectParsedGitSpec(spec: string) { const parsed = parseGitPluginSpec(spec); if (!parsed) { throw new Error(`Expected ${spec} to parse as a git plugin spec`); } return parsed; } function commandArgvAt(index: number): string[] { const call = runCommandWithTimeoutMock.mock.calls[index]; if (!call) { throw new Error(`expected command run #${index + 1}`); } return call[0] as string[]; } function firstInstallOptions(): | { expectedPluginId?: string; emitSuccessSecurityEvent?: boolean; packageDir?: string; mode?: string; installPolicyRequest?: { kind?: string; requestedSpecifier?: string }; } | undefined { return installPluginFromInstalledPackageDirMock.mock.calls[0]?.[0] as | { expectedPluginId?: string; emitSuccessSecurityEvent?: boolean; packageDir?: string; mode?: string; installPolicyRequest?: { kind?: string; requestedSpecifier?: string }; } | undefined; } function captureSecurityEvents(): { events: DiagnosticSecurityEvent[]; stop: () => void; } { const events: DiagnosticSecurityEvent[] = []; const stop = onInternalDiagnosticEvent((event, metadata) => { if (metadata.trusted && event.type === "security.event") { events.push(event); } }); return { events, stop }; } describe("parseGitPluginSpec", () => { it("normalizes GitHub shorthand and ref selectors", () => { const explicitRef = expectParsedGitSpec("git:github.com/acme/demo@v1.2.3"); expect(explicitRef.url).toBe("https://github.com/acme/demo.git"); expect(explicitRef.ref).toBe("v1.2.3"); expect(explicitRef.label).toBe("acme/demo"); expect(explicitRef.normalizedSpec).toBe("git:https://github.com/acme/demo.git@v1.2.3"); const slashRef = expectParsedGitSpec("git:acme/demo@feature/foo"); expect(slashRef.url).toBe("https://github.com/acme/demo.git"); expect(slashRef.ref).toBe("feature/foo"); expect(slashRef.label).toBe("acme/demo"); const hashRef = expectParsedGitSpec("git:acme/demo#main"); expect(hashRef.url).toBe("https://github.com/acme/demo.git"); expect(hashRef.ref).toBe("main"); }); it("does not treat URL credentials as ref selectors", () => { const parsed = expectParsedGitSpec("git:https://token:secret@github.com/acme/demo.git"); expect(parsed.url).toBe("https://token:secret@github.com/acme/demo.git"); expect(parsed.ref).toBeUndefined(); expect(parsed.label).toBe("github.com/acme/demo"); }); it("keeps scp-style clone URLs without treating git@ as a ref", () => { const parsed = expectParsedGitSpec("git:git@github.com:acme/demo.git@feature/foo"); expect(parsed.url).toBe("git@github.com:acme/demo.git"); expect(parsed.ref).toBe("feature/foo"); expect(parsed.label).toBe("git@github.com:acme/demo"); }); }); describe("isImmutableGitCommitRef", () => { it.each([ [undefined, false], ["main", false], ["v1.2.3", false], ["abc123", false], ["0123456789abcdef0123456789abcdef01234567", true], ["0123456789ABCDEF0123456789ABCDEF01234567", true], ] as const)("classifies %s as immutable=%s", (ref, expected) => { expect(isImmutableGitCommitRef(ref)).toBe(expected); }); }); describe("installPluginFromGitSpec", () => { const tempDirs: string[] = []; beforeEach(async () => { runCommandWithTimeoutMock.mockReset(); installPluginFromInstalledPackageDirMock.mockReset(); preflightPluginGitInstallPolicyMock.mockReset(); preflightPluginGitInstallPolicyMock.mockResolvedValue(null); const globalConfigRoot = await fs.mkdtemp( path.join(os.tmpdir(), "openclaw-git-install-npmrc-"), ); tempDirs.push(globalConfigRoot); const globalConfig = path.join(globalConfigRoot, "global-npmrc"); await fs.writeFile(globalConfig, "", "utf8"); vi.stubEnv("NPM_CONFIG_GLOBALCONFIG", globalConfig); }); afterEach(async () => { vi.unstubAllEnvs(); await Promise.all( tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), ); }); it("clones, checks out refs, installs from the clone, and returns commit metadata", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); installPluginFromInstalledPackageDirMock.mockImplementation( async (params: { packageDir: string }) => { await fs.mkdir(params.packageDir, { recursive: true }); return { ok: true, pluginId: "demo", targetDir: params.packageDir, version: "1.2.3", extensions: ["index.js"], }; }, ); const captured = captureSecurityEvents(); let result: Awaited>; try { result = await installPluginFromGitSpec({ spec: "git:github.com/acme/demo@v1.2.3", expectedPluginId: "demo", }); } finally { captured.stop(); } expect(result.ok).toBe(true); if (!result.ok) { throw new Error(result.error); } expect(result.pluginId).toBe("demo"); expect(result.git.url).toBe("https://github.com/acme/demo.git"); expect(result.git.ref).toBe("v1.2.3"); expect(result.git.commit).toBe("abc123"); const cloneArgv = commandArgvAt(0); expect(cloneArgv.slice(0, 3)).toEqual(["git", "clone", "https://github.com/acme/demo.git"]); expect(cloneArgv[3]).toContain("/repo"); expect(commandArgvAt(1)).toEqual(["git", "switch", "--detach", "--", "v1.2.3"]); expect(commandArgvAt(3)).toEqual([ "npm", "install", "--omit=dev", "--loglevel=error", "--ignore-scripts", "--no-audit", "--no-fund", ]); const installOptions = firstInstallOptions(); expect(installOptions?.expectedPluginId).toBe("demo"); expect(installOptions?.packageDir).toContain("/repo"); expect(installOptions?.installPolicyRequest?.kind).toBe("plugin-git"); expect(installOptions?.installPolicyRequest?.requestedSpecifier).toBe( "git:github.com/acme/demo@v1.2.3", ); expect(installOptions?.emitSuccessSecurityEvent).toBe(false); expect(captured.events).toHaveLength(1); expect(captured.events[0]).toMatchObject({ action: "plugin.installed", outcome: "success", target: { kind: "plugin", name: "demo" }, attributes: { source_family: "git", mode: "install", extension_count: 1, has_version: true, }, }); }); it("does not emit git install success when committing the managed repo fails", async () => { const gitRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-fail-")); const gitDir = path.join(gitRoot, "not-a-directory"); await fs.writeFile(gitDir, "file blocks nested managed repo creation", "utf8"); try { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); installPluginFromInstalledPackageDirMock.mockImplementation( async (params: { packageDir: string }) => { await fs.mkdir(params.packageDir, { recursive: true }); await fs.writeFile(path.join(params.packageDir, "package.json"), "{}", "utf8"); return { ok: true, pluginId: "demo", targetDir: params.packageDir, version: "1.2.3", extensions: ["index.js"], }; }, ); const captured = captureSecurityEvents(); let result: Awaited>; try { result = await installPluginFromGitSpec({ spec: "git:github.com/acme/demo", gitDir, }); } finally { captured.stop(); } expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("failed to replace managed git plugin repository"); } expect(firstInstallOptions()?.emitSuccessSecurityEvent).toBe(false); expect(captured.events).toHaveLength(0); } finally { await fs.rm(gitRoot, { recursive: true, force: true }); } }); it("uses a shallow clone when no ref is requested", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); installPluginFromInstalledPackageDirMock.mockImplementation( async (params: { packageDir: string }) => { await fs.mkdir(params.packageDir, { recursive: true }); return { ok: true, pluginId: "demo", targetDir: params.packageDir, version: "1.2.3", extensions: ["index.js"], }; }, ); const result = await installPluginFromGitSpec({ spec: "git:github.com/acme/demo" }); expect(result.ok).toBe(true); if (!result.ok) { throw new Error(result.error); } const cloneArgv = commandArgvAt(0); expect(cloneArgv.slice(0, 5)).toEqual([ "git", "clone", "--depth", "1", "https://github.com/acme/demo.git", ]); expect(cloneArgv[5]).toContain("/repo"); }); it("runs install policy preflight before npm installs git dependencies", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); preflightPluginGitInstallPolicyMock.mockResolvedValueOnce({ blocked: { reason: "blocked by install policy: git installs disabled", code: "security_scan_blocked", }, }); const captured = captureSecurityEvents(); let result: Awaited>; try { result = await installPluginFromGitSpec({ spec: "git:github.com/acme/demo", expectedPluginId: "demo", }); } finally { captured.stop(); } expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("git installs disabled"); } expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2); expect(commandArgvAt(0).slice(0, 5)).toEqual([ "git", "clone", "--depth", "1", "https://github.com/acme/demo.git", ]); expect(commandArgvAt(1)).toEqual(["git", "rev-parse", "HEAD"]); expect(preflightPluginGitInstallPolicyMock).toHaveBeenCalledWith( expect.objectContaining({ pluginId: "demo", requestedSpecifier: "git:github.com/acme/demo", source: { kind: "git", authority: "third-party", mutable: true, network: true }, sourcePath: expect.stringContaining("/repo"), }), ); expect(installPluginFromInstalledPackageDirMock).not.toHaveBeenCalled(); expect(captured.events).toHaveLength(1); expect(captured.events[0]).toMatchObject({ action: "plugin.audit.failed", outcome: "denied", target: { kind: "plugin", name: "demo" }, attributes: { source_family: "git", mode: "install", }, }); }); it("emits git audit errors when install policy preflight fails", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); preflightPluginGitInstallPolicyMock.mockResolvedValueOnce({ blocked: { reason: "install policy unavailable", code: "security_scan_failed", }, }); const captured = captureSecurityEvents(); let result: Awaited>; try { result = await installPluginFromGitSpec({ spec: "git:file:///Users/example/private-plugin", }); } finally { captured.stop(); } expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("install policy unavailable"); } expect(installPluginFromInstalledPackageDirMock).not.toHaveBeenCalled(); expect(captured.events).toHaveLength(1); expect(captured.events[0]).toMatchObject({ action: "plugin.audit.failed", outcome: "error", target: { kind: "plugin" }, attributes: { source_family: "git", mode: "install", }, }); expect(captured.events[0]?.target).not.toHaveProperty("name"); }); it("reports full commit refs as immutable to install policy", async () => { const commit = "0123456789abcdef0123456789abcdef01234567"; runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: `${commit}\n`, stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); installPluginFromInstalledPackageDirMock.mockImplementation( async (params: { packageDir: string }) => { await fs.mkdir(params.packageDir, { recursive: true }); return { ok: true, pluginId: "demo", targetDir: params.packageDir, version: "1.2.3", extensions: ["index.js"], }; }, ); const result = await installPluginFromGitSpec({ spec: `git:github.com/acme/demo@${commit}`, expectedPluginId: "demo", }); expect(result.ok).toBe(true); expect(preflightPluginGitInstallPolicyMock).toHaveBeenCalledWith( expect.objectContaining({ requestedSpecifier: `git:github.com/acme/demo@${commit}`, source: { kind: "git", authority: "third-party", mutable: false, network: true }, }), ); }); it("reports effective install mode for requested git update without an installed target", async () => { const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-mode-")); try { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); installPluginFromInstalledPackageDirMock.mockImplementation( async (params: { packageDir: string }) => { await fs.mkdir(params.packageDir, { recursive: true }); return { ok: true, pluginId: "demo", targetDir: params.packageDir, version: "1.2.3", extensions: ["index.js"], }; }, ); const result = await installPluginFromGitSpec({ spec: "git:github.com/acme/demo", expectedPluginId: "demo", gitDir, mode: "update", }); expect(result.ok).toBe(true); expect(preflightPluginGitInstallPolicyMock).toHaveBeenCalledWith( expect.objectContaining({ mode: "install" }), ); expect(firstInstallOptions()?.mode).toBe("install"); } finally { await fs.rm(gitDir, { recursive: true, force: true }); } }); it("uses a credential-free managed repo path for authenticated git URLs", async () => { const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-path-")); try { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); installPluginFromInstalledPackageDirMock.mockImplementation( async (params: { packageDir: string }) => { await fs.mkdir(params.packageDir, { recursive: true }); return { ok: true, pluginId: "demo", targetDir: params.packageDir, version: "1.2.3", extensions: ["index.js"], }; }, ); const result = await installPluginFromGitSpec({ spec: "git:https://token@github.com/acme/demo.git", gitDir, }); expect(result.ok).toBe(true); if (!result.ok) { throw new Error(result.error); } expect(result.targetDir).toBe( expectedGitRepoDir({ gitDir, normalizedSpec: "git:https://token@github.com/acme/demo.git", }), ); expect(result.targetDir).not.toContain("token"); expect(result.targetDir).not.toContain("github.com"); } finally { await fs.rm(gitDir, { recursive: true, force: true }); } }); it("redacts authenticated git URLs from command failure details", async () => { runCommandWithTimeoutMock.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "fatal: could not read Username for 'https://token:secret@github.com/acme/demo.git' while retrying https://other:credential@github.com/acme/fallback.git", }); const result = await installPluginFromGitSpec({ spec: "git:https://token:secret@github.com/acme/demo.git", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("failed to clone github.com/acme/demo"); expect(result.error).toContain("https://***:***@github.com/acme/demo.git"); expect(result.error).toContain("https://***:***@github.com/acme/fallback.git"); expect(result.error).not.toContain("token"); expect(result.error).not.toContain("secret"); expect(result.error).not.toContain("other"); expect(result.error).not.toContain("credential"); } expect(installPluginFromInstalledPackageDirMock).not.toHaveBeenCalled(); }); it("separates requested refs from git options", async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 128, stdout: "", stderr: "fatal: invalid reference: --ignore-skip-worktree-bits", }); const result = await installPluginFromGitSpec({ spec: "git:github.com/acme/demo@--ignore-skip-worktree-bits", }); expect(result.ok).toBe(false); expect(commandArgvAt(1)).toEqual([ "git", "switch", "--detach", "--", "--ignore-skip-worktree-bits", ]); expect(installPluginFromInstalledPackageDirMock).not.toHaveBeenCalled(); }); it("keeps the existing managed repo when replacement install fails", async () => { const gitDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-git-install-preserve-")); const normalizedSpec = "git:https://github.com/acme/demo.git"; const existingRepoDir = expectedGitRepoDir({ gitDir, normalizedSpec }); const markerPath = path.join(existingRepoDir, "existing.txt"); try { await fs.mkdir(existingRepoDir, { recursive: true }); await fs.writeFile(markerPath, "keep"); runCommandWithTimeoutMock .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }) .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "npm failed" }); const result = await installPluginFromGitSpec({ spec: "git:https://github.com/acme/demo.git", gitDir, mode: "update", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("npm install failed"); } await expect(fs.readFile(markerPath, "utf8")).resolves.toBe("keep"); expect(installPluginFromInstalledPackageDirMock).not.toHaveBeenCalled(); } finally { await fs.rm(gitDir, { recursive: true, force: true }); } }); });