santifer/career-ops · error · Error
refusing non-GitHub/unsafe repo URL
Error message
refusing non-GitHub/unsafe repo URL: ${arg} (expected https://github.com/<owner>/<repo>) What it means
parseRepoArg normalizes an `owner/repo` shorthand or a full URL into a GitHub HTTPS URL, then validates it against GITHUB_URL_RE. Anything that does not resolve to https://github.com/<owner>/<repo> is refused with this message to prevent installing plugins from arbitrary or malicious hosts (a supply-chain / SSRF guard).
Solutions
- Pass `owner/repo` shorthand or the canonical https://github.com/<owner>/<repo> URL
- Convert ssh URLs: git@github.com:owner/repo.git → https://github.com/owner/repo
- Strip extra path suffixes (/tree/main, /issues) — only owner/repo is accepted
- Check for typos in scheme and host (must be https and github.com)
Example fix
// before
await installFromRepo(root, { url: 'git@github.com:acme/career-ops-plugin-demo.git', sha });
// after
await installFromRepo(root, { url: 'acme/career-ops-plugin-demo', sha }); // or 'https://github.com/acme/career-ops-plugin-demo' Defensive patterns
Strategy: validation
Validate before calling
const ok = (a) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(a) || /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\.git)?$/.test(a.replace(/\.git$/, ''));
Type guard
const isGithubRepoArg = (a) => typeof a === 'string' && (a.includes('/') ? /^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(a.replace(/\.git$/, '')) : /^[^/]+\/[^/]+$/.test(a)); Try / catch
try { const { url, id } = parseRepoArg(arg); } catch (e) { if (e.message.startsWith('refusing non-GitHub/unsafe repo URL')) { console.error('Use owner/repo or https://github.com/owner/repo'); } else throw e; } Prevention
- Always pass owner/repo shorthand or the plain https://github.com/owner/repo URL
- Convert ssh clone URLs before passing them
- Strip /tree/<branch> and similar UI suffixes from copied URLs
- Treat the refusal as a security feature — do not try to bypass it
When it happens
Trigger: installFromRepo/parseRepoArg called with a GitLab or Bitbucket URL, an ssh:// or git@ form, a URL with extra path segments, a localhost/file:// URL, or a bare repo name without owner/.
Common situations: User copies a clone URL in ssh form (git@github.com:owner/repo.git) from GitHub's UI; typo like http:// (not https) with extra segments; pointing at an internal mirror; passing a raw repo slug with slashes in the wrong place.
Understand the failure class
Background: "Invalid URL" errors: why new URL(), URI.parse, and reqwest::Url reject your string — missing scheme, whitespace, and bad path format — this error's family across 39 libraries.
Related errors
- arbeitnow: URL must use HTTPS
- eightfold: URL must use HTTPS
- justjoin: untrusted hostname
- local-parser: careers_url must be http(s)
- manfred: URL must use HTTPS
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/bc2b6fec859974a3.
Report an issue: GitHub.
Appendix: source
Thrown at plugin-install.mjs:33
import { tmpdir } from 'node:os';
import path from 'node:path';
import { validateManifest } from './plugins/_engine.mjs';
import { hashPluginTree } from './plugins/_lock.mjs';
import { auditPlugin } from './plugin-audit.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
const GITHUB_URL_RE = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?(?:\.git)?$/;
const NAME_RE = /^career-ops-plugin-([a-z0-9][a-z0-9-]*)$/;
const SHA_RE = /^[0-9a-f]{40}$/;
const MIN_FILES = ['manifest.json', 'index.mjs', 'README.md', 'LICENSE'];
/** Normalize `owner/repo` | full URL into a validated github URL + the plugin id. */
export function parseRepoArg(arg) {
let url = arg;
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(arg)) url = `https://github.com/${arg}`;
url = url.replace(/\.git$/, '');
if (!GITHUB_URL_RE.test(url)) throw new Error(`refusing non-GitHub/unsafe repo URL: ${arg} (expected https://github.com/<owner>/<repo>)`);
const repoName = url.split('/').pop() || '';
const m = NAME_RE.exec(repoName);
if (!m) throw new Error(`repo must be named "career-ops-plugin-<name>" (got "${repoName}")`);
return { url, id: m[1] };
}
/** Clone the EXACT pinned SHA into a fresh temp dir. Returns the temp dir path. */
export function safeClone(url, sha) {
if (!SHA_RE.test(sha || '')) throw new Error(`a 40-hex commit --sha is required (got ${JSON.stringify(sha)})`);
const dir = mkdtempSync(path.join(tmpdir(), 'co-plugin-'));
const git = (...args) => execFileSync('git', ['-c', 'protocol.ext.allow=never', '-c', 'protocol.file.allow=never', ...args], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 120_000 });
try {
git('-C', dir, 'init', '-q');
git('-C', dir, 'remote', 'add', 'origin', '--', url);
git('-C', dir, 'fetch', '--depth', '1', '--no-tags', '-q', 'origin', sha);
git('-C', dir, 'checkout', '-q', 'FETCH_HEAD');
rmSync(path.join(dir, '.git'), { recursive: true, force: true }); // drop VCS metadata (and any hooks)
return dir;View on GitHub (pinned to aac998c7ed)