diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index 0bf5bb1073b..8a7dcd61b75 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -31,10 +31,10 @@ import { // `canonical: true` network path (see resolveLatestManifest) instead of an // explicit local `source` — that's the whole point (it must NOT read a stale // local repo manifest). Stub the two network boundaries it can reach so those -// tests stay fast and offline: `git ls-remote` (remoteHeadSha) always "fails" +// tests stay fast and offline: `git ls-remote` (remoteHeadSha) "fails" by default // so it falls back to the branch URL, and `fetch` is stubbed per-test. `vi.mock` -// is hoisted above these imports regardless of source position. No existing -// test in this file omits `source`, so nothing else touches this mock. +// is hoisted above these imports regardless of source position. Tests that need +// ls-remote to answer queue their own `mockImplementationOnce`. vi.mock("node:child_process", () => ({ execFile: vi.fn( ( @@ -677,3 +677,74 @@ describe("checkSkills canonical bypass of the in-repo manifest shortcut", () => expect(res.skills.map((s) => s.name)).toEqual(["from-explicit-source"]); }); }); + +describe("checkSkills remote HEAD lookup", () => { + beforeEach(() => { + // mockClear would keep a queued mockImplementationOnce; mockReset drops it. + vi.mocked(execFile).mockReset(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ + ok: true, + json: async () => ({ source: "owner/repo", skills: {} }), + }) as unknown as Response, + ), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("asks git for main without any credential helper or askpass prompt", async () => { + await checkSkills({ source: "owner/repo", cwd: root, home: root }); + expect(vi.mocked(execFile)).toHaveBeenCalledWith( + "git", + [ + "-c", + "credential.helper=", + "ls-remote", + "https://github.com/owner/repo.git", + "refs/heads/main", + ], + expect.objectContaining({ + env: expect.objectContaining({ + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + GCM_INTERACTIVE: "never", + }), + }), + expect.any(Function), + ); + }); + + it("pins a valid slug's manifest to the commit ls-remote reports", async () => { + const sha = "a".repeat(40); + // execFile is overloaded; this stub models only the callback form promisify calls. + const lsRemote = (( + _cmd: string, + _args: readonly string[], + _opts: unknown, + callback: (err: Error | null, out: { stdout: string; stderr: string }) => void, + ) => + callback(null, { + stdout: `${sha}\trefs/heads/main\n`, + stderr: "", + })) as unknown as typeof execFile; + vi.mocked(execFile).mockImplementationOnce(lsRemote); + await checkSkills({ source: "owner/repo", cwd: root, home: root }); + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + `https://raw.githubusercontent.com/owner/repo/${sha}/${MANIFEST_FILE}`, + expect.anything(), + ); + }); + + it.each(["--source owner/repo", "owner", "owner/repo/extra", "owner/repo?x=1"])( + "never runs git for the malformed slug %j", + async (source) => { + await checkSkills({ source, cwd: root, home: root }); + expect(vi.mocked(execFile)).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index ad9f9aa0de6..49e660f5fe8 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -95,6 +95,7 @@ export interface SkillsCheckResult { } const DEFAULT_REPO_SLUG = "heygen-com/hyperframes"; +const REPO_SLUG_PATTERN = /^[\w.-]+\/[\w.-]+$/; /** Manifest filename, published at the repo root. */ export const MANIFEST_FILE = "skills-manifest.json"; const FETCH_TIMEOUT_MS = 4000; @@ -588,19 +589,35 @@ async function fetchManifest(url: string): Promise { } /** - * Resolve main's live HEAD sha via `git ls-remote`. GitHub's branch-raw CDN - * (raw.githubusercontent.com///main/...) can serve stale content - * for minutes after a push; a SHA-pinned raw URL is immediately consistent. - * Returns null when git/network is unavailable so callers fall back to main. + * Resolve main's live HEAD sha via `git ls-remote`: the branch-raw CDN can serve + * stale content for minutes after a push, a SHA-pinned raw URL cannot. Returns + * null for a slug that is not `owner/repo`, or when git/network is unavailable. + * + * GIT_TERMINAL_PROMPT=0 does not stop credential helpers or askpass, and Git + * Credential Manager (the Git for Windows default) answers a private or missing + * repo's auth challenge with a blocking sign-in window. An empty credential.helper + * clears every helper; an empty GIT_ASKPASS also skips core.askPass and SSH_ASKPASS. */ async function remoteHeadSha(repoSlug: string): Promise { + if (!REPO_SLUG_PATTERN.test(repoSlug)) return null; try { const { stdout } = await execFileAsync( "git", - ["ls-remote", `https://github.com/${repoSlug}.git`, "refs/heads/main"], + [ + "-c", + "credential.helper=", + "ls-remote", + `https://github.com/${repoSlug}.git`, + "refs/heads/main", + ], { timeout: FETCH_TIMEOUT_MS, - env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + GCM_INTERACTIVE: "never", + }, windowsHide: true, }, );