Skip to content

Commit 875aa18

Browse files
committed
fix(server): reject non-git URLs at /api/repositories
A leader agent in an empty-remote project improvised `file:///...` as the repo URL. normalizeGitUrl silently passed it through, extractFullName returned null, and the daemon looped on `gh repo clone null file:/...` until the task was cancelled. Close the contract end-to-end: normalizeGitUrl whitelists https/http/ssh with at least host/owner/repo, strips trailing .git/slash combinations, and throws 400 on anything else. extractFullName becomes a contract function (invariant: input already normalized) and throws 500 on non-match, tightening full_name from string|null to string. Also fix findOrCreateRepository to only swallow UNIQUE constraint errors and apply withFullName to the fetched row. ak-plan skill Phase 0 now explicitly forbids inventing URLs when `git remote -v` is empty — stop and ask the user to push first.
1 parent 2288bcd commit 875aa18

4 files changed

Lines changed: 204 additions & 22 deletions

File tree

apps/web/server/repositoryRepo.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
import type { CreateRepositoryInput, Repository } from "@agent-kanban/shared";
2+
import { HTTPException } from "hono/http-exception";
23
import { type D1, newId } from "./db";
34

4-
/** Normalize git URL to canonical HTTPS form without .git suffix */
5+
/**
6+
* Normalize git URL to canonical HTTPS form without .git suffix or trailing slash.
7+
* Accepts: https://host/owner/repo, http://host/owner/repo, git@host:owner/repo.
8+
* Rejects everything else (file://, local paths, single-segment paths, unknown schemes)
9+
* with 400 — agents cannot invent URLs, users cannot paste local paths, and the daemon
10+
* must never see an un-cloneable URL.
11+
*/
512
export function normalizeGitUrl(url: string): string {
613
const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
714
if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;
8-
return url.replace(/\.git$/, "");
15+
if (/^https?:\/\/[^/]+\/[^/]+\/.+/.test(url)) {
16+
return url.replace(/(?:\.git|\/)+$/, "");
17+
}
18+
throw new HTTPException(400, {
19+
message: `Invalid repository URL "${url}": only https://host/owner/repo, http://host/owner/repo, or git@host:owner/repo are accepted`,
20+
});
921
}
1022

11-
/** Extract owner/repo from canonical HTTPS URL */
12-
function extractFullName(httpsUrl: string): string | null {
13-
const match = httpsUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
14-
return match ? match[1] : null;
23+
/** Extract owner/repo from a canonicalized URL. Invariant: the URL has already passed normalizeGitUrl. */
24+
function extractFullName(canonicalUrl: string): string {
25+
const match = canonicalUrl.match(/^https?:\/\/[^/]+\/(.+)$/);
26+
if (!match) {
27+
throw new HTTPException(500, {
28+
message: `Repository URL "${canonicalUrl}" has no owner/repo — data invariant broken (bypassed normalizeGitUrl)`,
29+
});
30+
}
31+
return match[1];
1532
}
1633

17-
function withFullName<T extends { url: string }>(repo: T): T & { full_name: string | null } {
34+
function withFullName<T extends { url: string }>(repo: T): T & { full_name: string } {
1835
return { ...repo, full_name: extractFullName(repo.url) };
1936
}
2037

@@ -32,16 +49,16 @@ export async function createRepository(db: D1, ownerId: string, input: CreateRep
3249
}
3350

3451
export async function findOrCreateRepository(db: D1, ownerId: string, input: CreateRepositoryInput): Promise<Repository> {
52+
const url = normalizeGitUrl(input.url);
3553
try {
3654
return await createRepository(db, ownerId, input);
37-
} catch {
38-
const existing = await db
39-
.prepare("SELECT * FROM repositories WHERE owner_id = ? AND url = ?")
40-
.bind(ownerId, normalizeGitUrl(input.url))
41-
.first<Repository>();
42-
if (existing) return existing;
43-
throw new Error("Failed to create or find repository");
55+
} catch (err) {
56+
const isUniqueViolation = err instanceof Error && err.message.includes("UNIQUE constraint failed");
57+
if (!isUniqueViolation) throw err;
4458
}
59+
const existing = await db.prepare("SELECT * FROM repositories WHERE owner_id = ? AND url = ?").bind(ownerId, url).first<Repository>();
60+
if (existing) return withFullName(existing);
61+
throw new Error("Failed to create or find repository");
4562
}
4663

4764
export async function listRepositories(db: D1, ownerId: string, filters?: { url?: string }): Promise<Repository[]> {

skills/ak-plan/SKILL.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,19 @@ Parse the user's input:
4545
Check if this is an **existing project** or a **new product**:
4646

4747
```bash
48-
git remote -v 2>/dev/null # has a repo? → existing project
48+
git remote -v 2>/dev/null # has a remote? → existing project
4949
ak get repo # registered repos
5050
```
5151

52-
- **Existing project**: has git remote → skip to Phase 1
53-
- **New product**: no repo → go to Phase 0.5 (Scaffold)
52+
Three possible states:
53+
54+
- **Existing project with remote** → skip to Phase 1
55+
- **New product (no git init yet)** → go to Phase 0.5 (Scaffold)
56+
- **Local-only project (git init done, no remote)** → STOP. A registered repo must have a real remote (`https://…` or `git@…`). Tell the user one of:
57+
1. Push the project to GitHub first: `gh repo create <owner>/<name> --source . --push`
58+
2. Or: ask them for the intended remote URL before proceeding.
59+
60+
**Never invent a URL** (no `file://`, no local paths, no placeholders). The agent-kanban server will reject non-http(s)/ssh URLs with 400, and even if it didn't, the daemon cannot clone local paths.
5461

5562
## Phase 0.5: Scaffold (new products only)
5663

@@ -67,9 +74,9 @@ cd <repo-dir>
6774
git add -A && git commit -m "feat: project scaffold" && git push -u origin main
6875
```
6976

70-
Register with agent-kanban:
77+
Register with agent-kanban (URL MUST come from `git remote get-url origin` — never hand-crafted):
7178
```bash
72-
ak create repo --name <name> --url <url>
79+
ak create repo --name <name> --url "$(git remote get-url origin)"
7380
```
7481

7582
The scaffold must contain enough structure for agents to start writing code immediately.
@@ -306,7 +313,7 @@ When all tasks are done, report the final summary to the user.
306313
- **Workflow completion is mandatory** — once this skill is invoked, the full lifecycle (plan → create → assign → monitor → review → merge all) MUST run to completion. If you are interrupted mid-workflow (user asks a side question, chat drifts to another topic, tool fails, etc.), handle the interruption and then **immediately resume the workflow from where you left off**. Never ask "should I continue monitoring?" or "do you want me to keep going?" — the answer is always yes. The only way to exit the workflow early is if the user explicitly says to stop, cancel, or abort.
307314
- **Follow CONTRIBUTING.md** — read the target repo's CONTRIBUTING.md before creating tasks; check PR compliance during review
308315
- **Prefer text output** — only use `-o json | jq` when extracting fields into variables (e.g. task IDs for `--depends-on`). For display, use default text output.
309-
- **Always get repo URL from `git remote -v`** — never guess
316+
- **Always get repo URL from `git remote get-url origin`** — never guess, never improvise. If there is no remote, stop and ask the user to push the repo first (see Phase 0). `file://`, local paths, and placeholder URLs will be rejected by the server with 400.
310317
- **Discuss the plan with the user before creating tasks** — don't just start creating
311318
- **Set depends-on at creation time** — don't leave deps for later
312319
- **Space API calls** — avoid triggering rate limits during batch creation

tests/repository-repo.test.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,86 @@ describe("normalizeGitUrl", () => {
3333
expect(normalizeGitUrl("https://github.com/org/repo")).toBe("https://github.com/org/repo");
3434
});
3535

36-
it("passes through URL with trailing slash as-is", async () => {
36+
it("strips trailing slash from HTTPS URL", async () => {
3737
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
38-
expect(normalizeGitUrl("https://github.com/org/repo/")).toBe("https://github.com/org/repo/");
38+
expect(normalizeGitUrl("https://github.com/org/repo/")).toBe("https://github.com/org/repo");
39+
});
40+
41+
it("accepts SSH git@ URL without .git suffix", async () => {
42+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
43+
expect(normalizeGitUrl("git@gitlab.com:myorg/myrepo")).toBe("https://gitlab.com/myorg/myrepo");
44+
});
45+
46+
it("accepts http:// URL with owner/repo path", async () => {
47+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
48+
expect(normalizeGitUrl("http://github.example.com/owner/repo")).toBe("http://github.example.com/owner/repo");
49+
});
50+
51+
it("rejects file:// URL with HTTPException 400", async () => {
52+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
53+
let thrown: unknown;
54+
try {
55+
normalizeGitUrl("file:///Users/alice/proj");
56+
} catch (err) {
57+
thrown = err;
58+
}
59+
expect((thrown as any).status).toBe(400);
60+
});
61+
62+
it("rejects bare local path with HTTPException 400", async () => {
63+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
64+
let thrown: unknown;
65+
try {
66+
normalizeGitUrl("/Users/alice/proj");
67+
} catch (err) {
68+
thrown = err;
69+
}
70+
expect((thrown as any).status).toBe(400);
71+
});
72+
73+
it("rejects plain string with no scheme with HTTPException 400", async () => {
74+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
75+
let thrown: unknown;
76+
try {
77+
normalizeGitUrl("my-repo");
78+
} catch (err) {
79+
thrown = err;
80+
}
81+
expect((thrown as any).status).toBe(400);
82+
});
83+
84+
it("rejects https:// URL with no owner/repo path with HTTPException 400", async () => {
85+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
86+
let thrown: unknown;
87+
try {
88+
normalizeGitUrl("https://github.com");
89+
} catch (err) {
90+
thrown = err;
91+
}
92+
expect((thrown as any).status).toBe(400);
93+
});
94+
95+
it("rejects single-segment HTTPS path with HTTPException 400", async () => {
96+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
97+
let thrown: unknown;
98+
try {
99+
normalizeGitUrl("https://github.com/single-segment");
100+
} catch (err) {
101+
thrown = err;
102+
}
103+
expect((thrown as any).status).toBe(400);
104+
});
105+
106+
it("strips .git and then trailing slash from https://github.com/org/repo/.git", async () => {
107+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
108+
// .git$ matches the end of "https://github.com/org/repo/.git", leaving "https://github.com/org/repo/"
109+
// trailing-slash strip then removes the slash → "https://github.com/org/repo"
110+
expect(normalizeGitUrl("https://github.com/org/repo/.git")).toBe("https://github.com/org/repo");
111+
});
112+
113+
it("accepts nested group paths (gitlab.com/group/subgroup/project)", async () => {
114+
const { normalizeGitUrl } = await import("../apps/web/server/repositoryRepo");
115+
expect(normalizeGitUrl("https://gitlab.com/group/subgroup/project")).toBe("https://gitlab.com/group/subgroup/project");
39116
});
40117
});
41118

@@ -101,11 +178,85 @@ describe("repositoryRepo", () => {
101178
url: "https://github.com/org/find-create-dup",
102179
});
103180
expect(second.id).toBe(first.id);
181+
expect(second.full_name).toBe("org/find-create-dup");
104182
});
105183

106184
it("repos are scoped to owner", async () => {
107185
const { listRepositories } = await import("../apps/web/server/repositoryRepo");
108186
const repos = await listRepositories(env.DB, "repo-test-user-2");
109187
expect(repos.length).toBe(0);
110188
});
189+
190+
it("createRepository rejects a file:// URL with 400", async () => {
191+
const { createRepository } = await import("../apps/web/server/repositoryRepo");
192+
let thrown: unknown;
193+
try {
194+
await createRepository(env.DB, "repo-test-user", { name: "bad-repo", url: "file:///Users/xudawei/skill-lake" });
195+
} catch (err) {
196+
thrown = err;
197+
}
198+
expect((thrown as any).status).toBe(400);
199+
});
200+
201+
it("findOrCreateRepository rejects a file:// URL with 400", async () => {
202+
const { findOrCreateRepository } = await import("../apps/web/server/repositoryRepo");
203+
let thrown: unknown;
204+
try {
205+
await findOrCreateRepository(env.DB, "repo-test-user", { name: "bad-repo", url: "file:///Users/xudawei/skill-lake" });
206+
} catch (err) {
207+
thrown = err;
208+
}
209+
expect((thrown as any).status).toBe(400);
210+
});
211+
212+
it("listRepositories rejects a file:// url filter with 400", async () => {
213+
const { listRepositories } = await import("../apps/web/server/repositoryRepo");
214+
let thrown: unknown;
215+
try {
216+
await listRepositories(env.DB, "repo-test-user", { url: "file:///Users/xudawei/skill-lake" });
217+
} catch (err) {
218+
thrown = err;
219+
}
220+
expect((thrown as any).status).toBe(400);
221+
});
222+
223+
it("getRepository returns null for unknown id", async () => {
224+
const { getRepository } = await import("../apps/web/server/repositoryRepo");
225+
const result = await getRepository(env.DB, "nonexistent-id", "repo-test-user");
226+
expect(result).toBeNull();
227+
});
228+
229+
it("getRepository returns the repo for a known id", async () => {
230+
const { createRepository, getRepository } = await import("../apps/web/server/repositoryRepo");
231+
const repo = await createRepository(env.DB, "repo-test-user", { name: "get-repo", url: "https://github.com/org/get-repo" });
232+
const result = await getRepository(env.DB, repo.id, "repo-test-user");
233+
expect(result).not.toBeNull();
234+
expect(result!.id).toBe(repo.id);
235+
expect(result!.full_name).toBe("org/get-repo");
236+
});
237+
238+
it("getRepository returns null when owner does not match", async () => {
239+
const { createRepository, getRepository } = await import("../apps/web/server/repositoryRepo");
240+
const repo = await createRepository(env.DB, "repo-test-user", { name: "scoped-repo", url: "https://github.com/org/scoped-repo" });
241+
const result = await getRepository(env.DB, repo.id, "repo-test-user-2");
242+
expect(result).toBeNull();
243+
});
244+
245+
it("listRepositories throws 500 when a stored URL bypassed normalizeGitUrl", async () => {
246+
// Directly insert a row with a file:// URL to simulate a corrupt DB row (invariant broken)
247+
const now = new Date().toISOString();
248+
await env.DB.prepare("INSERT INTO repositories (id, owner_id, name, url, created_at) VALUES (?, ?, ?, ?, ?)")
249+
.bind("corrupt-id-1", "repo-test-user", "corrupt-repo", "file:///bad/path", now)
250+
.run();
251+
const { listRepositories } = await import("../apps/web/server/repositoryRepo");
252+
let thrown: unknown;
253+
try {
254+
await listRepositories(env.DB, "repo-test-user");
255+
} catch (err) {
256+
thrown = err;
257+
}
258+
expect((thrown as any).status).toBe(500);
259+
// Clean up the corrupt row so it doesn't affect other tests
260+
await env.DB.prepare("DELETE FROM repositories WHERE id = ?").bind("corrupt-id-1").run();
261+
});
111262
});

tests/routes.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,13 @@ describe("routes", () => {
285285
expect(res.status).toBe(404);
286286
});
287287

288+
it("POST /api/repositories rejects file:// URL with 400", async () => {
289+
const res = await apiRequest("POST", "/api/repositories", { name: "x", url: "file:///tmp/x" }, userToken);
290+
expect(res.status).toBe(400);
291+
const body = (await res.json()) as any;
292+
expect(body.error.message).toMatch(/file:\/\/\/tmp\/x/);
293+
});
294+
288295
// ─── Agents ───
289296

290297
it("GET /api/agents lists agents", async () => {

0 commit comments

Comments
 (0)