mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-10-19 09:13:28 +00:00
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:
parent
762508e9eb
commit
c907359fb1
4 changed files with 119 additions and 9 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.25",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
67
packages/cli/src/__tests__/repo-url.test.ts
Normal file
67
packages/cli/src/__tests__/repo-url.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue