feat(cli): --repo accepts any git URL, not just GitHub slugs (#3364)

Adds normalizeRepoUrl() to accept:
- GitHub shorthand (user/repo) — still expanded to https://github.com/...
- HTTP(S) URLs (any host)
- ssh:// and git:// URLs
- SCP-style git@host:path

The clone URL is shellQuote'd before interpolation; values containing
shell metacharacters, whitespace, NUL bytes, or a leading `-` (which
git would parse as a flag) are rejected.

Bumps CLI to 1.0.23.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-04-29 09:28:37 -07:00 • committed by GitHub
parent 762508e9eb
commit c907359fb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 9 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.24",
"version": "1.0.25",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -0,0 +1,67 @@
// Unit tests for normalizeRepoUrl — accepts any sane git URL, rejects
// anything with shell metacharacters or that could be parsed as a git flag.
import { describe, expect, it } from "bun:test";
import { normalizeRepoUrl } from "../shared/orchestrate.js";
describe("normalizeRepoUrl", () => {
it("expands GitHub shorthand to a full HTTPS URL", () => {
expect(normalizeRepoUrl("openrouterteam/spawn")).toBe("https://github.com/openrouterteam/spawn.git");
expect(normalizeRepoUrl("user/my-repo")).toBe("https://github.com/user/my-repo.git");
expect(normalizeRepoUrl("user.name/repo.dot")).toBe("https://github.com/user.name/repo.dot.git");
});
it("passes through HTTPS URLs unchanged", () => {
expect(normalizeRepoUrl("https://github.com/user/repo.git")).toBe("https://github.com/user/repo.git");
expect(normalizeRepoUrl("https://gitlab.com/user/repo")).toBe("https://gitlab.com/user/repo");
expect(normalizeRepoUrl("http://gitea.example.internal/x/y")).toBe("http://gitea.example.internal/x/y");
expect(normalizeRepoUrl("https://bitbucket.org/team/repo.git")).toBe("https://bitbucket.org/team/repo.git");
});
it("passes through SSH URLs", () => {
expect(normalizeRepoUrl("ssh://git@github.com/user/repo.git")).toBe("ssh://git@github.com/user/repo.git");
expect(normalizeRepoUrl("git://github.com/user/repo.git")).toBe("git://github.com/user/repo.git");
});
it("passes through SCP-style SSH (git@host:path)", () => {
expect(normalizeRepoUrl("git@github.com:user/repo.git")).toBe("git@github.com:user/repo.git");
expect(normalizeRepoUrl("deploy@gitlab.example.com:team/repo")).toBe("deploy@gitlab.example.com:team/repo");
});
it("rejects shell metacharacters", () => {
expect(normalizeRepoUrl("user/repo; rm -rf /")).toBeNull();
expect(normalizeRepoUrl("user/repo`whoami`")).toBeNull();
expect(normalizeRepoUrl("$(curl evil)")).toBeNull();
expect(normalizeRepoUrl("user/repo|cat")).toBeNull();
expect(normalizeRepoUrl("user/repo && evil")).toBeNull();
expect(normalizeRepoUrl('user/"repo"')).toBeNull();
});
it("rejects embedded whitespace and NUL bytes", () => {
expect(normalizeRepoUrl("user / repo")).toBeNull();
expect(normalizeRepoUrl("user/re po")).toBeNull();
expect(normalizeRepoUrl("user\nrepo")).toBeNull();
expect(normalizeRepoUrl("user/repo\0")).toBeNull();
});
it("rejects leading `-` (git option masquerade)", () => {
expect(normalizeRepoUrl("--upload-pack=evil")).toBeNull();
expect(normalizeRepoUrl("-bad/repo")).toBeNull();
});
it("rejects gibberish that's neither a URL nor a slug", () => {
expect(normalizeRepoUrl("just-a-name")).toBeNull();
expect(normalizeRepoUrl("")).toBeNull();
expect(normalizeRepoUrl(" ")).toBeNull();
expect(normalizeRepoUrl("/etc/passwd")).toBeNull();
expect(normalizeRepoUrl("../../etc/passwd")).toBeNull();
});
it("rejects absurdly long inputs", () => {
expect(normalizeRepoUrl(`https://github.com/${"x".repeat(600)}/y`)).toBeNull();
});
it("trims surrounding whitespace before validating", () => {
expect(normalizeRepoUrl(" user/repo ")).toBe("https://github.com/user/repo.git");
});
});

View file

@ -147,7 +147,7 @@ function checkUnknownFlags(args: string[]): void {
console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`);
console.error(` ${pc.cyan("--config <path>")} Load config from JSON file`);
console.error(` ${pc.cyan("--steps <list>")} Comma-separated setup steps to enable`);
console.error(` ${pc.cyan("--repo <user/repo>")} Clone a template repo and apply spawn.md`);
console.error(` ${pc.cyan("--repo <slug|url>")} Clone a template repo and apply spawn.md`);
console.error(` ${pc.cyan("--beta tarball")} Use pre-built tarball for agent install (repeatable)`);
console.error(` ${pc.cyan("--beta images")} Use pre-built DO marketplace images (faster boot)`);
console.error(` ${pc.cyan("--beta parallel")} Parallelize server boot with setup prompts`);

View file

@ -63,6 +63,49 @@ function trackFunnel(step: string, extra: Record<string, unknown> = {}): void {
});
}
/**
* Normalize a `--repo` argument into a git clone URL.
*
* Accepts:
* - GitHub shorthand: user/repo → https://github.com/user/repo.git
* - HTTP(S) URL: https://host/path[.git] → unchanged
* - SSH URL: ssh://user@host/path → unchanged
* - SCP-style SSH: git@host:path → unchanged
* - git:// URL: git://host/path → unchanged
*
* Returns null for anything that contains shell metacharacters, whitespace,
* leading `-` (would be parsed as a git option), or doesn't look like a URL
* or `user/repo` slug at all. Defense in depth — the URL is always passed
* through `shellQuote` at the call site as well.
*/
export function normalizeRepoUrl(input: string): string | null {
const trimmed = input.trim();
if (trimmed.length === 0 || trimmed.length > 500) {
return null;
}
// No shell metacharacters, no whitespace, no NUL bytes
if (/[\s\0`$;&|<>(){}[\]"'\\!*?#]/.test(trimmed)) {
return null;
}
// Reject leading `-` so the URL can't masquerade as a git flag
if (trimmed.startsWith("-")) {
return null;
}
// Full URL with scheme
if (/^(https?|git|ssh|git\+ssh|git\+https?):\/\//i.test(trimmed)) {
return trimmed;
}
// SCP-style: user@host:path (host must contain a dot to disambiguate from GitHub shorthand)
if (/^[a-zA-Z0-9_.-]+@[a-zA-Z0-9.-]+\.[a-zA-Z0-9.-]+:[a-zA-Z0-9._/~-]+(\.git)?$/.test(trimmed)) {
return trimmed;
}
// GitHub shorthand: user/repo
if (/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(trimmed)) {
return `https://github.com/${trimmed}.git`;
}
return null;
}
/** Docker container name used by --beta docker deployments. */
export const DOCKER_CONTAINER_NAME = "spawn-agent";
/** Docker registry hosting spawn agent images. */
@ -623,15 +666,15 @@ async function postInstall(
// MCP servers, setup commands).
let spawnMdConfig: import("./spawn-md.js").SpawnMdConfig | null = null;
let repoCloned = false;
const repoSlug = process.env.SPAWN_REPO;
if (repoSlug && cloud.cloudName !== "local") {
// Validate slug format (user/repo, no path traversal)
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoSlug)) {
logWarn(`Invalid repo slug: ${repoSlug} — skipping repo clone`);
const repoArg = process.env.SPAWN_REPO;
if (repoArg && cloud.cloudName !== "local") {
const cloneUrl = normalizeRepoUrl(repoArg);
if (!cloneUrl) {
logWarn(`Invalid --repo value: ${repoArg} — skipping repo clone`);
} else {
logStep("Cloning template repository...");
const cloneResult = await asyncTryCatch(() =>
cloud.runner.runServer(`git clone https://github.com/${repoSlug}.git ~/project`),
cloud.runner.runServer(`git clone ${shellQuote(cloneUrl)} ~/project`),
);
if (!cloneResult.ok) {
logWarn(`Repo clone failed (${getErrorMessage(cloneResult.error)}) — continuing without template`);
@ -640,7 +683,7 @@ async function postInstall(
const { readRemoteSpawnMd } = await import("./spawn-md.js");
spawnMdConfig = await readRemoteSpawnMd(cloud.runner);
if (spawnMdConfig) {
logInfo(`Template loaded: ${spawnMdConfig.name ?? repoSlug}`);
logInfo(`Template loaded: ${spawnMdConfig.name ?? repoArg}`);
}
}
}