zuka
zuka/test/live/wire_test.ts

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/test/live/wire_test.ts
TSwire_test.ts86.2 KBDownload
1/**
2 * Live suite: spawns the compiled binary and drives it with a real `git` CLI.
3 *
4 * This is where the wire protocol and the REST contract are actually proven — the
5 * inline Rust tests cover units, but only a real clone and push prove M1. CI sets
6 * ZUKA_LIVE_TEST; without it these skip so a plain `deno test` stays fast.
7 */
8import {
9 assert,
10 assertEquals,
11 assertRejects,
12 assertStringIncludes,
13} from "jsr:@std/assert@^1";
14
15const ENABLED = Deno.env.get("ZUKA_LIVE_TEST") === "1";
16const ROOT = new URL("../../", import.meta.url).pathname;
17
18/** Crate name, read from Cargo.toml so a rename does not break the suite. */
19const NAME = (Deno.readTextFileSync(`${ROOT}Cargo.toml`)
20 .match(/^name\s*=\s*"([^"]+)"/m)?.[1]) ?? "zuka";
21/**
22 * The binary under test.
23 *
24 * Overridable because CI builds a static musl binary and deploys that exact file —
25 * so that is the one the live suite has to exercise. Testing a second, differently
26 * linked build and shipping the first proves nothing about what ships.
27 */
28const BINARY = Deno.env.get("ZUKA_TEST_BINARY") ??
29 `${ROOT}target/release/${NAME}`;
30
31interface Server {
32 port: number;
33 sshPort: number;
34 dataDir: string;
35 workDir: string;
36 token: string;
37 /// Private key registered with the `alice` account.
38 keyPath: string;
39 stop: () => Promise<void>;
40}
41
42async function freePort(): Promise<number> {
43 const listener = Deno.listen({ port: 0 });
44 const { port } = listener.addr as Deno.NetAddr;
45 listener.close();
46 return port;
47}
48
49/** Mint a token by invoking the binary's own command, as an operator would. */
50async function mintToken(
51 dataDir: string,
52 account: string,
53 extra: string[] = ["--scopes", "admin"],
54): Promise<string> {
55 const command = new Deno.Command(BINARY, {
56 args: ["token", account, ...extra],
57 env: { ZUKA_DATA_DIR: dataDir },
58 stdout: "piped",
59 stderr: "null",
60 });
61 const { success, stdout } = await command.output();
62 assert(success, `minting a token for ${account} failed`);
63 return new TextDecoder().decode(stdout).trim();
64}
65
66/** Generate a keypair and register the public half with an account. */
67async function registerKey(
68 dataDir: string,
69 account: string,
70 path: string,
71 extra: string[] = [],
72): Promise<void> {
73 const keygen = await new Deno.Command("ssh-keygen", {
74 args: ["-t", "ed25519", "-N", "", "-f", path, "-q"],
75 stdout: "null",
76 stderr: "null",
77 }).output();
78 assert(keygen.success, "ssh-keygen failed");
79
80 const add = await new Deno.Command(BINARY, {
81 args: ["key", account, `${path}.pub`, ...extra],
82 env: { ZUKA_DATA_DIR: dataDir },
83 stdout: "null",
84 stderr: "null",
85 }).output();
86 assert(add.success, `registering a key for ${account} failed`);
87}
88
89/** Run git over SSH with a specific identity, pinned to this server. */
90function sshEnv(
91 server: Server,
92 keyPath = server.keyPath,
93): Record<string, string> {
94 return {
95 GIT_SSH_COMMAND:
96 `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null` +
97 ` -o LogLevel=ERROR -o IdentitiesOnly=yes -o BatchMode=yes`,
98 };
99}
100
101function sshUrl(server: Server, account: string, repo: string) {
102 return `ssh://git@127.0.0.1:${server.sshPort}/${account}/${repo}.git`;
103}
104
105/** Raw SSH exec, for proving what a session may NOT do. */
106async function sshExec(server: Server, command: string[]): Promise<string> {
107 const { stdout, stderr } = await new Deno.Command("ssh", {
108 args: [
109 "-i",
110 server.keyPath,
111 "-o",
112 "StrictHostKeyChecking=no",
113 "-o",
114 "UserKnownHostsFile=/dev/null",
115 "-o",
116 "LogLevel=ERROR",
117 "-o",
118 "IdentitiesOnly=yes",
119 "-o",
120 "BatchMode=yes",
121 "-p",
122 String(server.sshPort),
123 "git@127.0.0.1",
124 ...command,
125 ],
126 stdout: "piped",
127 stderr: "piped",
128 }).output();
129 const decode = new TextDecoder();
130 return decode.decode(stdout) + decode.decode(stderr);
131}
132
133async function start(extra: Record<string, string> = {}): Promise<Server> {
134 const workDir = await Deno.makeTempDir({ prefix: "zuka-live-" });
135 const dataDir = `${workDir}/data`;
136 await Deno.mkdir(dataDir, { recursive: true });
137
138 const port = await freePort();
139 const sshPort = await freePort();
140 const token = await mintToken(dataDir, "alice");
141 const keyPath = `${workDir}/id_alice`;
142 await registerKey(dataDir, "alice", keyPath);
143
144 const child = new Deno.Command(BINARY, {
145 env: {
146 ZUKA_DATA_DIR: dataDir,
147 ZUKA_BIND: `127.0.0.1:${port}`,
148 ZUKA_SSH_BIND: `127.0.0.1:${sshPort}`,
149 PATH: Deno.env.get("PATH") ?? "/usr/bin:/bin",
150 ...extra,
151 },
152 stdout: "null",
153 // The server logs two lines per request. Left undrained this fills the 64 KiB
154 // pipe and hangs it, so it is discarded rather than piped.
155 stderr: "null",
156 }).spawn();
157
158 // Poll readiness rather than sleeping — a fixed sleep is either slow or flaky.
159 const deadline = Date.now() + 10_000;
160 for (;;) {
161 try {
162 const probe = await fetch(`http://127.0.0.1:${port}/healthz`);
163 await drain(probe);
164 if (probe.ok) break;
165 } catch {
166 // not listening yet
167 }
168 if (Date.now() > deadline) throw new Error("server did not become ready");
169 await new Promise((r) => setTimeout(r, 50));
170 }
171
172 return {
173 port,
174 sshPort,
175 dataDir,
176 workDir,
177 token,
178 keyPath,
179 stop: async () => {
180 try {
181 child.kill("SIGKILL");
182 } catch {
183 // already gone
184 }
185 await child.status;
186 await Deno.remove(workDir, { recursive: true }).catch(() => {});
187 },
188 };
189}
190
191async function git(
192 args: string[],
193 cwd: string,
194 env: Record<string, string> = {},
195): Promise<{ code: number; stdout: string; stderr: string }> {
196 const command = new Deno.Command("git", {
197 args: [
198 "-c",
199 "user.email=agent@example.com",
200 "-c",
201 "user.name=Agent",
202 "-c",
203 "protocol.version=2",
204 ...args,
205 ],
206 cwd,
207 env: { ...Deno.env.toObject(), GIT_TERMINAL_PROMPT: "0", ...env },
208 stdout: "piped",
209 stderr: "piped",
210 });
211 const { code, stdout, stderr } = await command.output();
212 return {
213 code,
214 stdout: new TextDecoder().decode(stdout),
215 stderr: new TextDecoder().decode(stderr),
216 };
217}
218
219function cloneUrl(
220 server: Server,
221 account: string,
222 repo: string,
223 token = server.token,
224) {
225 return `http://x-access-token:${token}@127.0.0.1:${server.port}/${account}/${repo}.git`;
226}
227
228async function api(
229 server: Server,
230 method: string,
231 path: string,
232 options: { token?: string; body?: unknown } = {},
233): Promise<Response> {
234 const headers: Record<string, string> = {};
235 const token = options.token ?? server.token;
236 if (token) headers["authorization"] = `Bearer ${token}`;
237 if (options.body !== undefined) headers["content-type"] = "application/json";
238
239 return await fetch(`http://127.0.0.1:${server.port}${path}`, {
240 method,
241 headers,
242 body: options.body === undefined ? undefined : JSON.stringify(options.body),
243 });
244}
245
246/** Read a response fully, so no test leaves a locked or dangling stream. */
247async function drain(response: Response): Promise<string> {
248 return await response.text();
249}
250
251async function json(response: Response): Promise<
252 // deno-lint-ignore no-explicit-any
253 any
254> {
255 return JSON.parse(await drain(response));
256}
257
258async function createRepo(server: Server, name: string) {
259 const response = await api(server, "POST", "/v1/repos", { body: { name } });
260 const body = await drain(response);
261 assertEquals(response.status, 201, body);
262}
263
264/** A second server with extra configuration, for tests that need a tighter limit. */
265async function startWith(extra: Record<string, string>): Promise<Server> {
266 return await start(extra);
267}
268
269/**
270 * Run the binary as a one-shot command against a running server's data directory.
271 *
272 * This is how an operator or a systemd timer invokes maintenance, so the tests
273 * exercise the same entry point rather than an internal function.
274 */
275async function runCli(
276 server: Server,
277 args: string[],
278 extra: Record<string, string>,
279): Promise<{ code: number; stdout: string; stderr: string }> {
280 const { code, stdout, stderr } = await new Deno.Command(BINARY, {
281 args,
282 env: {
283 ZUKA_DATA_DIR: server.dataDir,
284 PATH: Deno.env.get("PATH") ?? "/usr/bin:/bin",
285 ...extra,
286 },
287 stdout: "piped",
288 stderr: "piped",
289 }).output();
290 const decode = new TextDecoder();
291 return { code, stdout: decode.decode(stdout), stderr: decode.decode(stderr) };
292}
293
294/** Loose objects in a bare repository — what accumulates without gc. */
295async function countLoose(gitDir: string): Promise<number> {
296 let count = 0;
297 for await (const bucket of Deno.readDir(`${gitDir}/objects`)) {
298 if (
299 !bucket.isDirectory || bucket.name === "pack" || bucket.name === "info"
300 ) continue;
301 for await (
302 const object of Deno.readDir(`${gitDir}/objects/${bucket.name}`)
303 ) {
304 if (object.isFile) count++;
305 }
306 }
307 return count;
308}
309
310function live(name: string, fn: (server: Server) => Promise<void>) {
311 Deno.test({
312 name,
313 ignore: !ENABLED,
314 async fn() {
315 const server = await start();
316 try {
317 await fn(server);
318 } finally {
319 await server.stop();
320 }
321 },
322 });
323}
324
325live(
326 "a repository round-trips through clone, commit, push and clone",
327 async (server) => {
328 await createRepo(server, "site");
329
330 const cloned = await git([
331 "clone",
332 cloneUrl(server, "alice", "site"),
333 "work",
334 ], server.workDir);
335 assertEquals(cloned.code, 0, cloned.stderr);
336
337 const repo = `${server.workDir}/work`;
338 await Deno.writeTextFile(`${repo}/README.md`, "hello from an agent\n");
339 assertEquals((await git(["add", "README.md"], repo)).code, 0);
340 assertEquals((await git(["commit", "-m", "add readme"], repo)).code, 0);
341
342 const pushed = await git(["push", "origin", "HEAD:refs/heads/main"], repo);
343 assertEquals(pushed.code, 0, pushed.stderr);
344
345 const verify = await git([
346 "clone",
347 cloneUrl(server, "alice", "site"),
348 "verify",
349 ], server.workDir);
350 assertEquals(verify.code, 0, verify.stderr);
351
352 const content = await Deno.readTextFile(
353 `${server.workDir}/verify/README.md`,
354 );
355 assertEquals(content, "hello from an agent\n");
356 },
357);
358
359live(
360 "the pushed commit is visible over the REST refs endpoint",
361 async (server) => {
362 await createRepo(server, "site");
363 const repo = `${server.workDir}/work`;
364 assertEquals(
365 (await git(
366 ["clone", cloneUrl(server, "alice", "site"), "work"],
367 server.workDir,
368 )).code,
369 0,
370 );
371 await Deno.writeTextFile(`${repo}/a.txt`, "a");
372 await git(["add", "."], repo);
373 await git(["commit", "-m", "a"], repo);
374 await git(["push", "origin", "HEAD:refs/heads/main"], repo);
375
376 const response = await api(server, "GET", "/v1/repos/alice/site/refs");
377 assertEquals(response.status, 200);
378 const body = await json(response);
379 assertEquals(body.items.length, 1);
380 assertEquals(body.items[0].name, "refs/heads/main");
381 assertEquals(body.items[0].type, "branch");
382 assert(
383 /^[0-9a-f]{40}$/.test(body.items[0].sha),
384 "sha must be a full object id",
385 );
386 },
387);
388
389live(
390 "an unauthenticated git request challenges so the credential helper runs",
391 async (server) => {
392 await createRepo(server, "site");
393 const response = await fetch(
394 `http://127.0.0.1:${server.port}/alice/site.git/info/refs?service=git-upload-pack`,
395 );
396 await drain(response);
397
398 assertEquals(response.status, 401);
399 const challenge = response.headers.get("www-authenticate") ?? "";
400 assertStringIncludes(challenge, "Basic realm=");
401 // git renders a JSON error body as noise at the user.
402 assert(
403 !(response.headers.get("content-type") ?? "").includes("problem+json"),
404 "the git paths must not answer with problem+json",
405 );
406 },
407);
408
409live(
410 "another account cannot read, clone or enumerate a repository",
411 async (server) => {
412 await createRepo(server, "site");
413 const bob = await mintToken(server.dataDir, "bob");
414
415 const read = await api(server, "GET", "/v1/repos/alice/site", {
416 token: bob,
417 });
418 await drain(read);
419 assertEquals(read.status, 404, "403 would confirm the repository exists");
420
421 const cloned = await git(
422 ["clone", cloneUrl(server, "alice", "site", bob), "steal"],
423 server.workDir,
424 );
425 assert(cloned.code !== 0, "a foreign account must not clone");
426
427 const list = await api(server, "GET", "/v1/repos", { token: bob });
428 assertEquals((await json(list)).items.length, 0);
429 },
430);
431
432live(
433 "names that would escape the data directory are refused",
434 async (server) => {
435 for (const name of ["../../etc", ".git", "a/b", "", "-leading", "x.lock"]) {
436 const response = await api(server, "POST", "/v1/repos", {
437 body: { name },
438 });
439 const body = await json(response);
440 assertEquals(response.status, 400, `${name} must be refused`);
441 assertStringIncludes(body.type, "/problems/invalid-name");
442 }
443 },
444);
445
446live(
447 "only the three smart-HTTP endpoints are routable under .git",
448 async (server) => {
449 await createRepo(server, "site");
450 for (
451 const path of [
452 "/alice/site.git/config",
453 "/alice/site.git/HEAD",
454 "/alice/site.git/objects/info/packs",
455 "/alice/site.git/info/refs",
456 ]
457 ) {
458 const response = await api(server, "GET", path);
459 await drain(response);
460 assertEquals(response.status, 404, `${path} must not be served`);
461 }
462 },
463);
464
465live(
466 "creating the same repository twice conflicts, including by case",
467 async (server) => {
468 await createRepo(server, "site");
469
470 const same = await api(server, "POST", "/v1/repos", {
471 body: { name: "site" },
472 });
473 assertEquals(same.status, 409);
474 assertStringIncludes((await json(same)).type, "/problems/repo-exists");
475
476 const cased = await api(server, "POST", "/v1/repos", {
477 body: { name: "Site" },
478 });
479 assertEquals(
480 cased.status,
481 409,
482 "a case variant would collide on a case-insensitive disk",
483 );
484 await drain(cased);
485 },
486);
487
488live(
489 "a deleted repository stops serving and is recoverable on disk",
490 async (server) => {
491 await createRepo(server, "site");
492
493 const deleted = await api(server, "DELETE", "/v1/repos/alice/site");
494 await drain(deleted);
495 assertEquals(deleted.status, 204);
496
497 const gone = await api(server, "GET", "/v1/repos/alice/site");
498 await drain(gone);
499 assertEquals(gone.status, 404);
500
501 const again = await api(server, "DELETE", "/v1/repos/alice/site");
502 await drain(again);
503 assertEquals(
504 again.status,
505 404,
506 "deleting an absent repo must not report success",
507 );
508
509 const graves = [...Deno.readDirSync(`${server.dataDir}/tmp/deleted`)];
510 assertEquals(
511 graves.length,
512 1,
513 "delete must move the repo aside, not remove it",
514 );
515 },
516);
517
518live("health reports mode and disk headroom", async (server) => {
519 const response = await api(server, "GET", "/healthz");
520 const body = await json(response);
521 assertEquals(response.status, 200);
522 assertEquals(body.status, "ok");
523 assertEquals(body.mode, "standalone");
524 assert(typeof body.disk_free_bytes === "number");
525});
526
527// ── SSH transport ──────────────────────────────────────────────────────────
528
529live("a repository round-trips over SSH", async (server) => {
530 await createRepo(server, "site");
531 const env = sshEnv(server);
532
533 const cloned = await git(
534 ["clone", sshUrl(server, "alice", "site"), "work"],
535 server.workDir,
536 env,
537 );
538 assertEquals(cloned.code, 0, cloned.stderr);
539
540 const repo = `${server.workDir}/work`;
541 await Deno.writeTextFile(`${repo}/README.md`, "over ssh\n");
542 await git(["add", "."], repo, env);
543 await git(["commit", "-m", "add readme"], repo, env);
544
545 const pushed = await git(
546 ["push", "origin", "HEAD:refs/heads/main"],
547 repo,
548 env,
549 );
550 assertEquals(pushed.code, 0, pushed.stderr);
551
552 // Fetching a repository that HAS content is the case that deadlocks if the
553 // client-to-stdin copy is joined on rather than pumped independently.
554 const verify = await git(
555 ["clone", sshUrl(server, "alice", "site"), "verify"],
556 server.workDir,
557 env,
558 );
559 assertEquals(verify.code, 0, verify.stderr);
560 assertEquals(
561 await Deno.readTextFile(`${server.workDir}/verify/README.md`),
562 "over ssh\n",
563 );
564});
565
566live(
567 "an SSH session cannot open a shell or run anything but git",
568 async (server) => {
569 await createRepo(server, "site");
570
571 assertStringIncludes(await sshExec(server, []), "not a shell");
572
573 for (
574 const command of [
575 ["cat /etc/passwd"],
576 ["scp -t /tmp"],
577 ["git-daemon"],
578 ]
579 ) {
580 const output = await sshExec(server, command);
581 assertStringIncludes(
582 output,
583 `${NAME}:`,
584 `${command} must be refused by us`,
585 );
586 assert(
587 !output.includes("root:"),
588 "a refused command must not have executed",
589 );
590 }
591 },
592);
593
594live(
595 "SSH refuses command injection and traversal in the repository path",
596 async (server) => {
597 await createRepo(server, "site");
598
599 for (
600 const command of [
601 "git-upload-pack '/alice/site.git'; id",
602 "git-upload-pack '/alice/site.git' && id",
603 "git-upload-pack '/alice/$(id).git'",
604 "git-upload-pack '/alice/../../etc/passwd'",
605 "git-upload-pack '//etc/shadow'",
606 "git-upload-pack '/a/b/c.git'",
607 ]
608 ) {
609 const output = await sshExec(server, [command]);
610 assertStringIncludes(output, `${NAME}:`, `${command} must be refused`);
611 assert(!output.includes("uid="), "injected command must not have run");
612 }
613 },
614);
615
616live("an unregistered SSH key cannot authenticate", async (server) => {
617 await createRepo(server, "site");
618
619 const rogue = `${server.workDir}/rogue`;
620 await new Deno.Command("ssh-keygen", {
621 args: ["-t", "ed25519", "-N", "", "-f", rogue, "-q"],
622 stdout: "null",
623 stderr: "null",
624 }).output();
625
626 const cloned = await git(
627 ["clone", sshUrl(server, "alice", "site"), "stolen"],
628 server.workDir,
629 sshEnv(server, rogue),
630 );
631 assert(cloned.code !== 0, "an unregistered key must not authenticate");
632});
633
634live("a read-only SSH key may clone but not push", async (server) => {
635 await createRepo(server, "site");
636
637 const readOnly = `${server.workDir}/id_readonly`;
638 await registerKey(server.dataDir, "alice", readOnly, ["--read-only"]);
639 const env = sshEnv(server, readOnly);
640
641 const cloned = await git(
642 ["clone", sshUrl(server, "alice", "site"), "ro"],
643 server.workDir,
644 env,
645 );
646 assertEquals(cloned.code, 0, cloned.stderr);
647
648 const repo = `${server.workDir}/ro`;
649 await Deno.writeTextFile(`${repo}/a.txt`, "a");
650 await git(["add", "."], repo, env);
651 await git(["commit", "-m", "a"], repo, env);
652
653 const pushed = await git(
654 ["push", "origin", "HEAD:refs/heads/main"],
655 repo,
656 env,
657 );
658 assert(pushed.code !== 0, "a read-only key must not push");
659});
660
661live(
662 "branch protection is enforced over SSH by the same rule",
663 async (server) => {
664 await createRepo(server, "site");
665 const env = sshEnv(server);
666 const repo = `${server.workDir}/work`;
667
668 await git(
669 ["clone", sshUrl(server, "alice", "site"), "work"],
670 server.workDir,
671 env,
672 );
673 await Deno.writeTextFile(`${repo}/a.txt`, "a");
674 await git(["add", "."], repo, env);
675 await git(["commit", "-m", "first"], repo, env);
676 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
677
678 // Unprotected: the rewrite goes through over SSH just as it does over HTTP.
679 await git(["commit", "--amend", "-m", "rewritten"], repo, env);
680 assertEquals(
681 (await git(
682 ["push", "--force", "origin", "HEAD:refs/heads/main"],
683 repo,
684 env,
685 )).code,
686 0,
687 );
688
689 await drain(
690 await api(server, "PATCH", "/v1/repos/alice/site", {
691 body: { protected_refs: ["refs/heads/main"] },
692 }),
693 );
694
695 const before = await json(
696 await api(server, "GET", "/v1/repos/alice/site/refs"),
697 );
698 await git(["commit", "--amend", "-m", "again"], repo, env);
699 const forced = await git(
700 ["push", "--force", "origin", "HEAD:refs/heads/main"],
701 repo,
702 env,
703 );
704
705 assert(forced.code !== 0, "protection must hold over SSH");
706 assertStringIncludes(forced.stderr, "protected");
707
708 const after = await json(
709 await api(server, "GET", "/v1/repos/alice/site/refs"),
710 );
711 assertEquals(after.items[0].sha, before.items[0].sha);
712 },
713);
714
715// ── Regressions ────────────────────────────────────────────────────────────
716
717live(
718 "a case-variant account cannot address another account's repository",
719 async (server) => {
720 await createRepo(server, "site");
721 const bob = await mintToken(server.dataDir, "bob");
722
723 // `Alice` folds to `alice`; bob must not reach it under either spelling.
724 for (const account of ["alice", "Alice", "ALICE"]) {
725 const response = await api(server, "GET", `/v1/repos/${account}/site`, {
726 token: bob,
727 });
728 await drain(response);
729 assertEquals(
730 response.status,
731 404,
732 `${account} must not be readable by bob`,
733 );
734 }
735
736 // The owner reaches the same single repository under any spelling.
737 const canonical = await json(
738 await api(server, "GET", "/v1/repos/Alice/SITE"),
739 );
740 assertEquals(canonical.account, "alice");
741 assertEquals(canonical.name, "site");
742 },
743);
744
745live(
746 "a repo-scoped admin token cannot delete a repository it cannot read",
747 async (server) => {
748 await createRepo(server, "site");
749 await createRepo(server, "secret");
750
751 const confined = await mintToken(server.dataDir, "alice", [
752 "--scopes",
753 "admin",
754 "--repos",
755 "site",
756 ]);
757
758 const read = await api(server, "GET", "/v1/repos/alice/secret", {
759 token: confined,
760 });
761 await drain(read);
762 assertEquals(read.status, 404);
763
764 const deleted = await api(server, "DELETE", "/v1/repos/alice/secret", {
765 token: confined,
766 });
767 await drain(deleted);
768 assertEquals(
769 deleted.status,
770 404,
771 "a token denied read must not be granted destroy",
772 );
773
774 const still = await api(server, "GET", "/v1/repos/alice/secret");
775 await drain(still);
776 assertEquals(still.status, 200, "the repository must still exist");
777 },
778);
779
780live(
781 "a duplicated service parameter is refused rather than resolved differently",
782 async (server) => {
783 await createRepo(server, "site");
784 const readOnly = await mintToken(server.dataDir, "alice", [
785 "--scopes",
786 "repo:read",
787 ]);
788
789 const single = await api(
790 server,
791 "GET",
792 "/alice/site.git/info/refs?service=git-receive-pack",
793 { token: readOnly },
794 );
795 await drain(single);
796 assertEquals(
797 single.status,
798 403,
799 "a reader must not get the push advertisement",
800 );
801
802 // This server takes the first value and the CGI takes the last, so a duplicate
803 // would let the authorization decision and the running service disagree.
804 const duplicated = await api(
805 server,
806 "GET",
807 "/alice/site.git/info/refs?service=git-upload-pack&service=git-receive-pack",
808 { token: readOnly },
809 );
810 const body = await drain(duplicated);
811 assertEquals(
812 duplicated.status,
813 404,
814 "a duplicated service parameter must not route",
815 );
816 assert(
817 !body.includes("receive-pack"),
818 "the write advertisement must not be reachable this way",
819 );
820 },
821);
822
823live("every git error is plain text, not problem+json", async (server) => {
824 await createRepo(server, "site");
825 const readOnly = await mintToken(server.dataDir, "alice", [
826 "--scopes",
827 "repo:read",
828 ]);
829
830 const cases: Array<[string, Record<string, unknown>]> = [
831 ["/alice/site.git/info/refs?service=git-receive-pack", { token: readOnly }],
832 ["/alice/absent.git/info/refs?service=git-upload-pack", {}],
833 ];
834
835 for (const [path, options] of cases) {
836 const response = await api(server, "GET", path, options);
837 const body = await drain(response);
838 assert(response.status >= 400, `${path} should have failed`);
839 assert(
840 !(response.headers.get("content-type") ?? "").includes("problem+json"),
841 `${path} answered problem+json, which git prints at the user`,
842 );
843 assert(!body.trimStart().startsWith("{"), `${path} answered JSON`);
844 }
845});
846
847live("a token expires and stops working", async (server) => {
848 await createRepo(server, "site");
849
850 // `--days 0` expires at the moment it is minted.
851 const expired = await mintToken(server.dataDir, "alice", [
852 "--scopes",
853 "admin",
854 "--days",
855 "0",
856 ]);
857
858 const response = await api(server, "GET", "/v1/repos", { token: expired });
859 await drain(response);
860 assertEquals(response.status, 401, "an expired token must not authenticate");
861});
862
863live("credential files are not world-readable", async (server) => {
864 await createRepo(server, "site");
865
866 for (const file of ["tokens.json", "ssh_keys.json", "ssh_host_ed25519_key"]) {
867 const info = await Deno.stat(`${server.dataDir}/${file}`);
868 const mode = (info.mode ?? 0) & 0o077;
869 assertEquals(mode, 0, `${file} is readable by group or other`);
870 }
871});
872
873// ── Read API ───────────────────────────────────────────────────────────────
874
875live("repository content is readable over REST", async (server) => {
876 await createRepo(server, "site");
877 const env = sshEnv(server);
878 const repo = `${server.workDir}/work`;
879
880 await git(
881 ["clone", sshUrl(server, "alice", "site"), "work"],
882 server.workDir,
883 env,
884 );
885 await Deno.mkdir(`${repo}/src`, { recursive: true });
886 await Deno.writeTextFile(`${repo}/README.md`, "# hello\n");
887 await Deno.writeTextFile(`${repo}/src/main.rs`, "fn main() {}\n");
888 await git(["add", "."], repo, env);
889 await git(["commit", "-m", "initial"], repo, env);
890 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
891
892 const tree = await json(
893 await api(server, "GET", "/v1/repos/alice/site/tree"),
894 );
895 const names = tree.items.map((e: { name: string }) => e.name).sort();
896 assertEquals(names, ["README.md", "src"]);
897
898 const nested = await json(
899 await api(server, "GET", "/v1/repos/alice/site/tree/src"),
900 );
901 assertEquals(nested.items[0].path, "src/main.rs");
902 assertEquals(nested.items[0].type, "file");
903
904 const raw = await api(server, "GET", "/v1/repos/alice/site/raw/README.md");
905 const body = await drain(raw);
906 assertEquals(body, "# hello\n");
907 assertEquals(raw.headers.get("content-type"), "application/octet-stream");
908 assertEquals(raw.headers.get("x-content-type-options"), "nosniff");
909
910 const log = await json(
911 await api(server, "GET", "/v1/repos/alice/site/commits"),
912 );
913 assertEquals(log.items.length, 1);
914 assertEquals(log.items[0].message, "initial");
915 assert(/^[0-9a-f]{40}$/.test(log.items[0].sha));
916});
917
918live(
919 "read endpoints refuse traversal and smuggled git arguments",
920 async (server) => {
921 await createRepo(server, "site");
922
923 for (
924 const path of [
925 "/v1/repos/alice/site/raw/../../../etc/passwd",
926 "/v1/repos/alice/site/tree/.git/config",
927 "/v1/repos/alice/site/commits?ref=--upload-pack%3Devil",
928 "/v1/repos/alice/site/commits/--help",
929 "/v1/repos/alice/site/commits?limit=999999",
930 ]
931 ) {
932 const response = await api(server, "GET", path);
933 await drain(response);
934 assert(
935 response.status >= 400,
936 `${path} must be refused, got ${response.status}`,
937 );
938 }
939 },
940);
941
942live(
943 "the OpenAPI document is served and matches the crate version",
944 async (server) => {
945 const response = await api(server, "GET", "/openapi.json");
946 const doc = await json(response);
947 assertEquals(response.status, 200);
948 assertEquals(doc.openapi, "3.1.0");
949 assert(
950 doc.paths["/v1/repos"],
951 "the document must describe the repo collection",
952 );
953 },
954);
955
956live(
957 "the product name is not baked into behaviour a rename would break",
958 async (server) => {
959 // Problem type URIs are built from one place and are configurable, so a rename
960 // or a self-hoster's own docs origin does not require touching call sites.
961 const response = await api(server, "POST", "/v1/repos", {
962 body: { name: "../escape" },
963 });
964 const body = await json(response);
965 assertEquals(response.status, 400);
966 assertStringIncludes(body.type, "/problems/invalid-name");
967
968 // The auth realm is derived too.
969 const challenge = await fetch(`http://127.0.0.1:${server.port}/v1/repos`);
970 await challenge.text();
971 assertStringIncludes(
972 challenge.headers.get("www-authenticate") ?? "",
973 "realm=",
974 );
975 },
976);
977
978live("repository settings are patchable", async (server) => {
979 await createRepo(server, "site");
980
981 const described = await json(
982 await api(server, "PATCH", "/v1/repos/alice/site", {
983 body: { description: "the shop" },
984 }),
985 );
986 assertEquals(described.description, "the shop");
987
988 // merge-patch: null clears, absent leaves alone.
989 const cleared = await json(
990 await api(server, "PATCH", "/v1/repos/alice/site", {
991 body: { description: null },
992 }),
993 );
994 assertEquals(cleared.description, undefined);
995 assertEquals(cleared.name, "site", "an absent field must be untouched");
996
997 // A default branch that does not exist would leave clones detached.
998 const bad = await api(server, "PATCH", "/v1/repos/alice/site", {
999 body: { default_branch: "refs/heads/nope" },
1000 });
1001 await drain(bad);
1002 assertEquals(bad.status, 409);
1003});
1004
1005// ── git, at full power ─────────────────────────────────────────────────────
1006
1007live("git archive --remote works over SSH", async (server) => {
1008 await createRepo(server, "site");
1009 const env = sshEnv(server);
1010 const repo = `${server.workDir}/work`;
1011
1012 await git(
1013 ["clone", sshUrl(server, "alice", "site"), "work"],
1014 server.workDir,
1015 env,
1016 );
1017 await Deno.writeTextFile(`${repo}/README.md`, "archived\n");
1018 await git(["add", "."], repo, env);
1019 await git(["commit", "-m", "initial"], repo, env);
1020 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1021
1022 const archived = await git(
1023 [
1024 "archive",
1025 "--remote",
1026 sshUrl(server, "alice", "site"),
1027 "--format=tar",
1028 "-o",
1029 `${server.workDir}/out.tar`,
1030 "refs/heads/main",
1031 ],
1032 server.workDir,
1033 env,
1034 );
1035 assertEquals(archived.code, 0, archived.stderr);
1036
1037 const info = await Deno.stat(`${server.workDir}/out.tar`);
1038 assert(info.size > 0, "the archive must contain something");
1039});
1040
1041live("a read-only key may archive but still may not push", async (server) => {
1042 await createRepo(server, "site");
1043 const env = sshEnv(server);
1044 const repo = `${server.workDir}/work`;
1045 await git(
1046 ["clone", sshUrl(server, "alice", "site"), "work"],
1047 server.workDir,
1048 env,
1049 );
1050 await Deno.writeTextFile(`${repo}/a.txt`, "a");
1051 await git(["add", "."], repo, env);
1052 await git(["commit", "-m", "a"], repo, env);
1053 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1054
1055 const readOnly = `${server.workDir}/id_ro_archive`;
1056 await registerKey(server.dataDir, "alice", readOnly, ["--read-only"]);
1057 const roEnv = sshEnv(server, readOnly);
1058
1059 const archived = await git(
1060 [
1061 "archive",
1062 "--remote",
1063 sshUrl(server, "alice", "site"),
1064 "--format=tar",
1065 "-o",
1066 `${server.workDir}/ro.tar`,
1067 "refs/heads/main",
1068 ],
1069 server.workDir,
1070 roEnv,
1071 );
1072 assertEquals(archived.code, 0, "archiving is a read and must be allowed");
1073});
1074
1075// ── discovery ──────────────────────────────────────────────────────────────
1076
1077/** Seed a repository with two commits and a small tree. */
1078async function seed(server: Server, name = "site") {
1079 await createRepo(server, name);
1080 const env = sshEnv(server);
1081 const repo = `${server.workDir}/${name}-work`;
1082 await git(
1083 ["clone", sshUrl(server, "alice", name), `${name}-work`],
1084 server.workDir,
1085 env,
1086 );
1087
1088 await Deno.mkdir(`${repo}/src`, { recursive: true });
1089 await Deno.writeTextFile(`${repo}/README.md`, "# project\nneedle-one\n");
1090 await Deno.writeTextFile(`${repo}/src/main.rs`, "fn main() {}\n");
1091 await git(["add", "."], repo, env);
1092 await git(["commit", "-m", "initial"], repo, env);
1093 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1094
1095 await Deno.writeTextFile(
1096 `${repo}/src/main.rs`,
1097 "fn main() {\n // needle-two\n}\n",
1098 );
1099 await git(["add", "."], repo, env);
1100 await git(["commit", "-m", "second"], repo, env);
1101 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1102
1103 return { repo, env };
1104}
1105
1106live("compare reports what changed between two points", async (server) => {
1107 await seed(server);
1108 const log = await json(
1109 await api(server, "GET", "/v1/repos/alice/site/commits"),
1110 );
1111 const [head, base] = log.items.map((c: { sha: string }) => c.sha);
1112
1113 const compared = await json(
1114 await api(
1115 server,
1116 "GET",
1117 `/v1/repos/alice/site/compare?base=${base}&head=${head}`,
1118 ),
1119 );
1120 assertEquals(compared.merge_base, base);
1121 assertEquals(compared.commits.length, 1);
1122 assertEquals(compared.changed.length, 1);
1123 assertEquals(compared.changed[0].path, "src/main.rs");
1124 assert(compared.additions > 0, "an edit should report additions");
1125});
1126
1127live(
1128 "search finds content and refuses to be used as a flag",
1129 async (server) => {
1130 await seed(server);
1131
1132 const found = await json(
1133 await api(server, "GET", "/v1/repos/alice/site/search?q=needle-two"),
1134 );
1135 assertEquals(found.items.length, 1);
1136 assertEquals(found.items[0].path, "src/main.rs");
1137
1138 const scoped = await json(
1139 await api(server, "GET", "/v1/repos/alice/site/search?q=needle&path=src"),
1140 );
1141 assert(
1142 scoped.items.every((m: { path: string }) => m.path.startsWith("src/")),
1143 );
1144
1145 const missing = await json(
1146 await api(server, "GET", "/v1/repos/alice/site/search?q=nothinghere"),
1147 );
1148 assertEquals(
1149 missing.items.length,
1150 0,
1151 "no matches is an answer, not an error",
1152 );
1153
1154 const noQuery = await api(server, "GET", "/v1/repos/alice/site/search");
1155 await drain(noQuery);
1156 assertEquals(noQuery.status, 400);
1157 },
1158);
1159
1160live("blame attributes each line to a commit", async (server) => {
1161 await seed(server);
1162 const blamed = await json(
1163 await api(server, "GET", "/v1/repos/alice/site/blame/src/main.rs"),
1164 );
1165 assertEquals(blamed.path, "src/main.rs");
1166 assert(blamed.items.length >= 3, "every line should be attributed");
1167 assert(/^[0-9a-f]{40}$/.test(blamed.items[0].sha));
1168 assertEquals(blamed.items[0].author, "Agent");
1169 assert(
1170 blamed.items.some((l: { content: string }) =>
1171 l.content.includes("needle-two")
1172 ),
1173 );
1174});
1175
1176live("a path-filtered log reports whether it was truncated", async (server) => {
1177 await seed(server);
1178 const filtered = await json(
1179 await api(server, "GET", "/v1/repos/alice/site/commits?path=src/main.rs"),
1180 );
1181 assertEquals(filtered.items.length, 2);
1182 assertEquals(filtered.truncated, false);
1183});
1184
1185// ── MCP ────────────────────────────────────────────────────────────────────
1186
1187let rpcId = 0;
1188async function rpc(
1189 server: Server,
1190 method: string,
1191 params: unknown = {},
1192 token = server.token,
1193 // deno-lint-ignore no-explicit-any
1194): Promise<any> {
1195 const response = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1196 method: "POST",
1197 headers: {
1198 authorization: `Bearer ${token}`,
1199 "content-type": "application/json",
1200 },
1201 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
1202 });
1203 return JSON.parse(await response.text());
1204}
1205
1206async function callTool(
1207 server: Server,
1208 name: string,
1209 args: Record<string, unknown> = {},
1210 token = server.token,
1211 // deno-lint-ignore no-explicit-any
1212): Promise<any> {
1213 return await rpc(server, "tools/call", { name, arguments: args }, token);
1214}
1215
1216live(
1217 "MCP handshake advertises tools and identifies the server",
1218 async (server) => {
1219 const init = await rpc(server, "initialize", {});
1220 assertEquals(init.jsonrpc, "2.0");
1221 assert(init.result.protocolVersion, "a protocol version is required");
1222 assert(
1223 init.result.capabilities.tools,
1224 "tools capability must be advertised",
1225 );
1226 assert(init.result.serverInfo.name);
1227
1228 // An agent must be told that writes go through git, not through a tool.
1229 assertStringIncludes(init.result.instructions, "git");
1230
1231 const listed = await rpc(server, "tools/list");
1232 const names = listed.result.tools.map((t: { name: string }) => t.name);
1233 for (
1234 const expected of [
1235 "repo_create",
1236 "key_add",
1237 "tree_list",
1238 "search",
1239 "blame",
1240 "compare",
1241 ]
1242 ) {
1243 assert(names.includes(expected), `${expected} must be offered`);
1244 }
1245 for (const banned of ["file_write", "commit_create", "push"]) {
1246 assert(!names.includes(banned), `${banned} would be a second write path`);
1247 }
1248 },
1249);
1250
1251live(
1252 "MCP handles unknown methods and notifications correctly",
1253 async (server) => {
1254 const unknown = await rpc(server, "nonsense/method");
1255 assertEquals(unknown.error.code, -32601);
1256
1257 // A notification carries no id and takes no reply.
1258 const notified = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1259 method: "POST",
1260 headers: {
1261 authorization: `Bearer ${server.token}`,
1262 "content-type": "application/json",
1263 },
1264 body: JSON.stringify({
1265 jsonrpc: "2.0",
1266 method: "notifications/initialized",
1267 }),
1268 });
1269 await notified.text();
1270 assertEquals(notified.status, 202);
1271
1272 const unauthenticated = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1273 method: "POST",
1274 headers: { "content-type": "application/json" },
1275 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
1276 });
1277 await unauthenticated.text();
1278 assertEquals(unauthenticated.status, 401);
1279 },
1280);
1281
1282live(
1283 "an agent provisions and inspects a repository entirely over MCP",
1284 async (server) => {
1285 // repo_create → key_add → git push → discovery. The whole product loop.
1286 const created = await callTool(server, "repo_create", { name: "shop" });
1287 const view = created.result.structuredContent;
1288 assertEquals(view.name, "shop");
1289 assert(view.clone_url_ssh, "the agent needs somewhere to push");
1290
1291 const keyPath = `${server.workDir}/id_mcp`;
1292 await new Deno.Command("ssh-keygen", {
1293 args: ["-t", "ed25519", "-N", "", "-f", keyPath, "-q"],
1294 stdout: "null",
1295 stderr: "null",
1296 }).output();
1297 const pub = await Deno.readTextFile(`${keyPath}.pub`);
1298
1299 const added = await callTool(server, "key_add", {
1300 title: "agent",
1301 key: pub,
1302 });
1303 assert(added.result.structuredContent.fingerprint.startsWith("SHA256:"));
1304
1305 // The key just registered must actually work for git.
1306 const env = sshEnv(server, keyPath);
1307 const repo = `${server.workDir}/shop`;
1308 assertEquals(
1309 (await git(
1310 ["clone", sshUrl(server, "alice", "shop"), "shop"],
1311 server.workDir,
1312 env,
1313 )).code,
1314 0,
1315 "a key registered over MCP must authenticate over SSH",
1316 );
1317 await Deno.writeTextFile(`${repo}/price.txt`, "10\n");
1318 await git(["add", "."], repo, env);
1319 await git(["commit", "-m", "add price"], repo, env);
1320 assertEquals(
1321 (await git(["push", "origin", "HEAD:refs/heads/main"], repo, env)).code,
1322 0,
1323 );
1324
1325 // Discovery sees what git pushed.
1326 const tree = await callTool(server, "tree_list", { repo: "shop" });
1327 const names = tree.result.structuredContent.items.map((
1328 e: { name: string },
1329 ) => e.name);
1330 assert(names.includes("price.txt"));
1331
1332 const read = await callTool(server, "file_read", {
1333 repo: "shop",
1334 path: "price.txt",
1335 });
1336 assertEquals(read.result.structuredContent.content, "10\n");
1337
1338 const refs = await callTool(server, "ref_list", { repo: "shop" });
1339 assertEquals(
1340 refs.result.structuredContent.items[0].name,
1341 "refs/heads/main",
1342 );
1343
1344 const listed = await callTool(server, "repo_list");
1345 const repos = listed.result.structuredContent.items.map((
1346 r: { name: string },
1347 ) => r.name);
1348 assert(repos.includes("shop"));
1349 },
1350);
1351
1352live(
1353 "MCP tool errors come back as tool results, not protocol failures",
1354 async (server) => {
1355 await createRepo(server, "site");
1356
1357 // A model should see the failure and adapt; a protocol error looks like a bug.
1358 const missing = await callTool(server, "file_read", {
1359 repo: "site",
1360 path: "nope.txt",
1361 });
1362 assertEquals(missing.result.isError, true);
1363 assertEquals(missing.error, undefined);
1364
1365 const badArgs = await callTool(server, "tree_list", {});
1366 assertEquals(badArgs.result.isError, true);
1367
1368 const unknownTool = await callTool(server, "not_a_tool", {});
1369 assertEquals(unknownTool.result.isError, true);
1370 },
1371);
1372
1373live("MCP enforces the same access rules as REST", async (server) => {
1374 await createRepo(server, "site");
1375 const bob = await mintToken(server.dataDir, "bob");
1376
1377 const foreign = await callTool(server, "repo_get", { repo: "site" }, bob);
1378 assertEquals(
1379 foreign.result.isError,
1380 true,
1381 "bob must not read alice's repository",
1382 );
1383
1384 const readOnly = await mintToken(server.dataDir, "alice", [
1385 "--scopes",
1386 "repo:read",
1387 ]);
1388 const denied = await callTool(
1389 server,
1390 "repo_create",
1391 { name: "nope" },
1392 readOnly,
1393 );
1394 assertEquals(denied.result.isError, true, "a read token must not create");
1395
1396 // Key management requires an unconfined admin token, as it does over REST.
1397 const confined = await mintToken(server.dataDir, "alice", [
1398 "--scopes",
1399 "admin",
1400 "--repos",
1401 "site",
1402 ]);
1403 const keys = await callTool(server, "key_list", {}, confined);
1404 assertEquals(keys.result.isError, true);
1405});
1406
1407live(
1408 "MCP discovery refuses traversal and smuggled git arguments",
1409 async (server) => {
1410 await seed(server);
1411
1412 for (
1413 const [tool, args] of [
1414 ["file_read", { repo: "site", path: "../../../etc/passwd" }],
1415 ["tree_list", { repo: "site", path: ".git" }],
1416 ["commit_log", { repo: "site", ref: "--upload-pack=evil" }],
1417 ["commit_get", { repo: "site", sha: "--help" }],
1418 ["compare", { repo: "site", base: "HEAD", head: "HEAD" }],
1419 ["blame", { repo: "site", path: ".git/config" }],
1420 ] as Array<[string, Record<string, unknown>]>
1421 ) {
1422 const result = await callTool(server, tool, args);
1423 assertEquals(
1424 result.result.isError,
1425 true,
1426 `${tool} must refuse ${JSON.stringify(args)}`,
1427 );
1428 }
1429 },
1430);
1431
1432// ── M4: limits, quotas and maintenance ─────────────────────────────────────
1433
1434live(
1435 "an over-quota account cannot create more repositories",
1436 async (server) => {
1437 // The harness server runs with the default repo cap; assert against what the
1438 // account endpoint reports rather than a number baked into the test.
1439 const account = await json(await api(server, "GET", "/v1/account"));
1440 assert(account.limits.max_repos > 0, "a repo cap must be configured");
1441 assertEquals(account.usage.repos, 0);
1442
1443 await createRepo(server, "one");
1444 const after = await json(await api(server, "GET", "/v1/account"));
1445 assertEquals(after.usage.repos, 1, "usage must track creation");
1446 },
1447);
1448
1449live(
1450 "quota enforcement is reported as a problem with limit and usage",
1451 async (server) => {
1452 // A dedicated server with a cap of one repository.
1453 const tight = await startWith({ ZUKA_MAX_REPOS_PER_ACCOUNT: "1" });
1454 try {
1455 await createRepo(tight, "first");
1456
1457 const refused = await api(tight, "POST", "/v1/repos", {
1458 body: { name: "second" },
1459 });
1460 const problem = await json(refused);
1461 assertEquals(refused.status, 413);
1462 assertStringIncludes(problem.type, "/problems/quota-exceeded");
1463 assertEquals(problem.limit, 1);
1464 assertEquals(problem.used, 1);
1465
1466 // Reads keep working: over-quota blocks writes only.
1467 const readable = await api(tight, "GET", "/v1/repos/alice/first");
1468 await drain(readable);
1469 assertEquals(readable.status, 200, "over-quota must not break reads");
1470 } finally {
1471 await tight.stop();
1472 }
1473 },
1474);
1475
1476live("a rate-limited caller is refused with Retry-After", async (_server) => {
1477 const limited = await startWith({ ZUKA_RATE_PER_MINUTE: "5" });
1478 try {
1479 // Set the repository up before spending the allowance.
1480 await createRepo(limited, "site");
1481
1482 let refused: Response | undefined;
1483 for (let i = 0; i < 20; i++) {
1484 const response = await api(limited, "GET", "/v1/account");
1485 await drain(response);
1486 if (response.status === 429) {
1487 refused = response;
1488 break;
1489 }
1490 }
1491 assert(refused, "a caller must eventually be rate limited");
1492 assert(
1493 refused.headers.get("retry-after"),
1494 "a 429 must say when to come back",
1495 );
1496
1497 // The git transport is deliberately NOT rate limited: a clone is one long
1498 // request, already bounded by the concurrency semaphore, and a per-minute
1499 // count is the wrong instrument for it.
1500 const cloned = await git(
1501 ["clone", cloneUrl(limited, "alice", "site"), "still-works"],
1502 limited.workDir,
1503 );
1504 assertEquals(
1505 cloned.code,
1506 0,
1507 `git must keep working while the API is rate limited: ${cloned.stderr}`,
1508 );
1509 } finally {
1510 await limited.stop();
1511 }
1512});
1513
1514live(
1515 "gc reclaims unreachable objects and leaves the repository intact",
1516 async (server) => {
1517 const { repo, env } = await seed(server, "churn");
1518 const gitDir = `${server.dataDir}/git/alice/churn.git`;
1519
1520 // Rewriting a branch orphans the objects the old tip referenced.
1521 await Deno.writeTextFile(
1522 `${repo}/src/main.rs`,
1523 "fn main() { /* rewritten */ }\n",
1524 );
1525 await git(["add", "."], repo, env);
1526 await git(["commit", "--amend", "-m", "rewritten"], repo, env);
1527 await git(["push", "--force", "origin", "HEAD:refs/heads/main"], repo, env);
1528
1529 const before = await countLoose(gitDir);
1530 assert(before > 0, "there should be loose objects to collect");
1531
1532 // Zero grace so the test does not have to wait two weeks.
1533 const gc = await runCli(server, ["gc"], { ZUKA_GC_PRUNE_GRACE: "now" });
1534 assertEquals(gc.code, 0, gc.stderr);
1535 assertStringIncludes(gc.stderr, "repacked");
1536
1537 const intact = await new Deno.Command("git", {
1538 args: [
1539 "--git-dir",
1540 gitDir,
1541 "fsck",
1542 "--no-progress",
1543 "--connectivity-only",
1544 ],
1545 stdout: "null",
1546 stderr: "piped",
1547 }).output();
1548 assertEquals(intact.code, 0, "gc must not corrupt the repository");
1549
1550 // The repository still serves after collection.
1551 const cloned = await git(
1552 ["clone", sshUrl(server, "alice", "churn"), "after-gc"],
1553 server.workDir,
1554 env,
1555 );
1556 assertEquals(cloned.code, 0, cloned.stderr);
1557 },
1558);
1559
1560live("gc runs safely while a clone is in flight", async (server) => {
1561 const { repo, env } = await seed(server, "busy");
1562
1563 // Make the repository big enough that the clone is still running when gc starts.
1564 // getRandomValues caps at 64 KiB per call, so fill in chunks. Incompressible so
1565 // the pack cannot be shrunk away and the clone actually takes time.
1566 const big = new Uint8Array(12 * 1024 * 1024);
1567 for (let offset = 0; offset < big.length; offset += 65536) {
1568 crypto.getRandomValues(
1569 big.subarray(offset, Math.min(offset + 65536, big.length)),
1570 );
1571 }
1572 await Deno.writeFile(`${repo}/blob.bin`, big);
1573 await git(["add", "."], repo, env);
1574 await git(["commit", "-m", "big"], repo, env);
1575 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1576
1577 const cloning = git(
1578 ["clone", sshUrl(server, "alice", "busy"), "concurrent"],
1579 server.workDir,
1580 env,
1581 );
1582 // Default grace, which is what makes this safe — not a lock.
1583 const collecting = runCli(server, ["gc"], {});
1584
1585 const [cloned, gc] = await Promise.all([cloning, collecting]);
1586 assertEquals(gc.code, 0, gc.stderr);
1587 assertEquals(
1588 cloned.code,
1589 0,
1590 `a clone must survive concurrent gc: ${cloned.stderr}`,
1591 );
1592
1593 const size = (await Deno.stat(`${server.workDir}/concurrent/blob.bin`)).size;
1594 assertEquals(size, big.length, "the cloned content must be complete");
1595});
1596
1597live("sweep reclaims deleted repositories once they expire", async (server) => {
1598 await createRepo(server, "doomed");
1599 await drain(await api(server, "DELETE", "/v1/repos/alice/doomed"));
1600
1601 const graves = () =>
1602 [...Deno.readDirSync(`${server.dataDir}/tmp/deleted`)].length;
1603 assertEquals(graves(), 1, "delete must keep the repository recoverable");
1604
1605 // Retention still running: nothing is reclaimed.
1606 const kept = await runCli(server, ["sweep"], {
1607 ZUKA_DELETED_RETENTION_DAYS: "7",
1608 });
1609 assertEquals(kept.code, 0);
1610 assertEquals(graves(), 1, "a fresh delete must stay recoverable");
1611
1612 const swept = await runCli(server, ["sweep"], {
1613 ZUKA_DELETED_RETENTION_DAYS: "0",
1614 });
1615 assertEquals(swept.code, 0, swept.stderr);
1616 assertStringIncludes(swept.stderr, "removed 1");
1617 assertEquals(graves(), 0, "an expired delete must be reclaimed");
1618});
1619
1620live("fsck finds an orphan in both directions", async (server) => {
1621 await createRepo(server, "paired");
1622 const clean = await runCli(server, ["fsck"], {});
1623 assertEquals(clean.code, 0, "a consistent store must exit zero");
1624
1625 // Metadata without a repository: what a crash mid-delete leaves.
1626 await Deno.remove(`${server.dataDir}/git/alice/paired.git`, {
1627 recursive: true,
1628 });
1629 const dangling = await runCli(server, ["fsck"], {});
1630 assertEquals(
1631 dangling.code,
1632 1,
1633 "an orphan must be reported as a non-zero exit",
1634 );
1635 assertStringIncludes(dangling.stdout, "record-without-repo");
1636
1637 // Repair removes the record, and the result is durable.
1638 const repaired = await runCli(server, ["fsck", "--repair"], {});
1639 assertEquals(repaired.code, 0);
1640 assertStringIncludes(repaired.stderr, "1 repaired");
1641 assertEquals((await runCli(server, ["fsck"], {})).code, 0);
1642
1643 // A repository without metadata: reported, never deleted.
1644 await createRepo(server, "lonely");
1645 await Deno.remove(`${server.dataDir}/meta/alice/lonely.json`);
1646 const lonely = await runCli(server, ["fsck", "--repair"], {});
1647 assertStringIncludes(lonely.stdout, "repo-without-record");
1648 assert(
1649 await Deno.stat(`${server.dataDir}/git/alice/lonely.git/HEAD`).then(() =>
1650 true
1651 ),
1652 "repair must never delete real repository bytes",
1653 );
1654});
1655
1656live(
1657 "newly created repositories do not run gc inside a push",
1658 async (server) => {
1659 await createRepo(server, "quiet");
1660 const gitDir = `${server.dataDir}/git/alice/quiet.git`;
1661
1662 for (
1663 const [key, expected] of [["gc.auto", "0"], ["receive.autogc", "false"]]
1664 ) {
1665 const value = await new Deno.Command("git", {
1666 args: ["--git-dir", gitDir, "config", "--get", key],
1667 stdout: "piped",
1668 stderr: "null",
1669 }).output();
1670 assertEquals(
1671 new TextDecoder().decode(value.stdout).trim(),
1672 expected,
1673 `${key} must be off so maintenance is scheduled, not opportunistic`,
1674 );
1675 }
1676 },
1677);
1678
1679// ── M5: CI ─────────────────────────────────────────────────────────────────
1680
1681/** Push a repository carrying a CI spec, on a server with CI enabled. */
1682async function seedCi(server: Server, name: string, spec: string) {
1683 await createRepo(server, name);
1684 const env = sshEnv(server);
1685 const repo = `${server.workDir}/${name}-ci`;
1686 await git(
1687 ["clone", sshUrl(server, "alice", name), `${name}-ci`],
1688 server.workDir,
1689 env,
1690 );
1691
1692 await Deno.writeTextFile(`${repo}/.zuka.toml`, spec);
1693 await git(["add", "."], repo, env);
1694 await git(["commit", "-m", "add ci"], repo, env);
1695 const pushed = await git(
1696 ["push", "origin", "HEAD:refs/heads/main"],
1697 repo,
1698 env,
1699 );
1700 assertEquals(pushed.code, 0, pushed.stderr);
1701 return { repo, env };
1702}
1703
1704/** Poll until a run reaches a terminal state. */
1705async function settle(server: Server, repo: string, timeoutMs = 30_000) {
1706 const deadline = Date.now() + timeoutMs;
1707 for (;;) {
1708 const body = await json(
1709 await api(server, "GET", `/v1/repos/alice/${repo}/runs`),
1710 );
1711 const run = body.items[0];
1712 if (run && !["queued", "running"].includes(run.status)) return run;
1713 if (Date.now() > deadline) {
1714 throw new Error(`run did not settle: ${JSON.stringify(body.items)}`);
1715 }
1716 await new Promise((r) => setTimeout(r, 200));
1717 }
1718}
1719
1720live(
1721 "a push triggers a run that succeeds and captures output",
1722 async (_server) => {
1723 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1724 try {
1725 await seedCi(
1726 ci,
1727 "green",
1728 '[run]\nsteps = ["echo hello-from-ci", "true"]\n',
1729 );
1730
1731 const run = await settle(ci, "green");
1732 assertEquals(run.status, "succeeded");
1733 assertEquals(run.exit_code, 0);
1734 assertEquals(run.git_ref, "refs/heads/main");
1735 assert(/^[0-9a-f]{40}$/.test(run.sha));
1736
1737 const logs = await drain(
1738 await api(ci, "GET", `/v1/repos/alice/green/runs/${run.id}/logs`),
1739 );
1740 assertStringIncludes(logs, "hello-from-ci");
1741 assertStringIncludes(logs, "all steps succeeded");
1742 } finally {
1743 await ci.stop();
1744 }
1745 },
1746);
1747
1748live(
1749 "a failing step fails the run and stops the remaining steps",
1750 async (_server) => {
1751 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1752 try {
1753 await seedCi(
1754 ci,
1755 "red",
1756 '[run]\nsteps = ["echo first", "exit 3", "echo never-runs"]\n',
1757 );
1758
1759 const run = await settle(ci, "red");
1760 assertEquals(run.status, "failed");
1761 assertEquals(run.exit_code, 3);
1762
1763 const logs = await drain(
1764 await api(ci, "GET", `/v1/repos/alice/red/runs/${run.id}/logs`),
1765 );
1766 assertStringIncludes(logs, "first");
1767 assert(!logs.includes("never-runs"), "a failed step must stop the run");
1768 } finally {
1769 await ci.stop();
1770 }
1771 },
1772);
1773
1774live("a run that exceeds its timeout is killed", async (_server) => {
1775 const ci = await startWith({
1776 ZUKA_CI_ENABLED: "1",
1777 ZUKA_CI_TIMEOUT_SECS: "2",
1778 });
1779 try {
1780 await seedCi(ci, "slow", '[run]\nsteps = ["sleep 60"]\ntimeout_secs = 2\n');
1781
1782 const run = await settle(ci, "slow", 30_000);
1783 assertEquals(run.status, "timed_out");
1784
1785 // The whole process group must be gone, not just the shell.
1786 const survivors = await new Deno.Command("pgrep", {
1787 args: ["-f", "sleep 60"],
1788 stdout: "piped",
1789 })
1790 .output();
1791 assertEquals(
1792 new TextDecoder().decode(survivors.stdout).trim(),
1793 "",
1794 "a timeout must kill the process group, not orphan it",
1795 );
1796 } finally {
1797 await ci.stop();
1798 }
1799});
1800
1801live("CI steps cannot read the server's environment", async (_server) => {
1802 const ci = await startWith({
1803 ZUKA_CI_ENABLED: "1",
1804 // A secret the service process holds. A step must not see it.
1805 ZUKA_TEST_SECRET: "hunter2",
1806 });
1807 try {
1808 await seedCi(ci, "envtest", '[run]\nsteps = ["env | sort"]\n');
1809
1810 const run = await settle(ci, "envtest");
1811 const logs = await drain(
1812 await api(ci, "GET", `/v1/repos/alice/envtest/runs/${run.id}/logs`),
1813 );
1814
1815 assert(
1816 !logs.includes("hunter2"),
1817 "the service's environment must be scrubbed",
1818 );
1819 assertStringIncludes(
1820 logs,
1821 "ZUKA_RUN_ID=",
1822 "a step should know its own run",
1823 );
1824 assertStringIncludes(logs, "PATH=", "a step needs to find its tools");
1825 } finally {
1826 await ci.stop();
1827 }
1828});
1829
1830live("a repository with no spec is a no-op, not a failure", async (_server) => {
1831 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1832 try {
1833 await createRepo(ci, "nospec");
1834 const env = sshEnv(ci);
1835 const repo = `${ci.workDir}/nospec-x`;
1836 await git(
1837 ["clone", sshUrl(ci, "alice", "nospec"), "nospec-x"],
1838 ci.workDir,
1839 env,
1840 );
1841 await Deno.writeTextFile(`${repo}/README.md`, "no ci here\n");
1842 await git(["add", "."], repo, env);
1843 await git(["commit", "-m", "no ci"], repo, env);
1844 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1845
1846 const run = await settle(ci, "nospec");
1847 assertEquals(run.status, "succeeded");
1848 assertEquals(run.detail, "no spec");
1849 } finally {
1850 await ci.stop();
1851 }
1852});
1853
1854live(
1855 "a spec may lower its timeout but never raise it past the host ceiling",
1856 async (_server) => {
1857 const ci = await startWith({
1858 ZUKA_CI_ENABLED: "1",
1859 ZUKA_CI_TIMEOUT_SECS: "3",
1860 });
1861 try {
1862 // The spec asks for an hour; the host allows three seconds.
1863 await seedCi(
1864 ci,
1865 "greedy",
1866 '[run]\nsteps = ["sleep 60"]\ntimeout_secs = 3600\n',
1867 );
1868
1869 const started = Date.now();
1870 const run = await settle(ci, "greedy", 30_000);
1871 assertEquals(run.status, "timed_out");
1872 assert(
1873 Date.now() - started < 25_000,
1874 "a repository must not be able to hold a runner past the host ceiling",
1875 );
1876 } finally {
1877 await ci.stop();
1878 }
1879 },
1880);
1881
1882live("runs are triggerable and cancellable over the API", async (_server) => {
1883 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1884 try {
1885 await seedCi(ci, "manual", '[run]\nsteps = ["sleep 30"]\n');
1886 await settle(ci, "manual", 40_000).catch(() => {});
1887
1888 const triggered = await api(ci, "POST", `/v1/repos/alice/manual/runs`, {
1889 body: { ref: "refs/heads/main" },
1890 });
1891 const queued = await json(triggered);
1892 assertEquals(triggered.status, 201);
1893 assertEquals(queued.status, "queued");
1894
1895 const cancelled = await json(
1896 await api(ci, "PATCH", `/v1/repos/alice/manual/runs/${queued.id}`, {
1897 body: { status: "cancelled" },
1898 }),
1899 );
1900 assertEquals(cancelled.status, "cancelled");
1901
1902 // Cancelling a finished run is a conflict, not a silent no-op.
1903 const again = await api(
1904 ci,
1905 "PATCH",
1906 `/v1/repos/alice/manual/runs/${queued.id}`,
1907 {
1908 body: { status: "cancelled" },
1909 },
1910 );
1911 await drain(again);
1912 assertEquals(again.status, 409);
1913 } finally {
1914 await ci.stop();
1915 }
1916});
1917
1918live("CI is off by default and triggering says so", async (server) => {
1919 await createRepo(server, "quiet");
1920 const health = await json(await api(server, "GET", "/healthz"));
1921 assertEquals(health.ci_enabled, false);
1922
1923 const refused = await api(server, "POST", "/v1/repos/alice/quiet/runs", {
1924 body: {},
1925 });
1926 const problem = await json(refused);
1927 assertEquals(refused.status, 409);
1928 assertStringIncludes(problem.type, "/problems/ci-disabled");
1929});
1930
1931live("an agent sees CI results over MCP", async (_server) => {
1932 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1933 try {
1934 await seedCi(ci, "mcpci", '[run]\nsteps = ["echo built-ok"]\n');
1935 await settle(ci, "mcpci");
1936
1937 const listed = await callTool(ci, "run_list", { repo: "mcpci" });
1938 const runs = listed.result.structuredContent.items;
1939 assertEquals(runs[0].status, "succeeded");
1940
1941 const logs = await callTool(ci, "run_logs", {
1942 repo: "mcpci",
1943 id: runs[0].id,
1944 });
1945 assertStringIncludes(logs.result.structuredContent.logs, "built-ok");
1946
1947 const one = await callTool(ci, "run_get", {
1948 repo: "mcpci",
1949 id: runs[0].id,
1950 });
1951 assertEquals(one.result.structuredContent.exit_code, 0);
1952 } finally {
1953 await ci.stop();
1954 }
1955});
1956
1957live("deleting a repository takes its runs with it", async (_server) => {
1958 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1959 try {
1960 await seedCi(ci, "doomed", '[run]\nsteps = ["true"]\n');
1961 await settle(ci, "doomed");
1962
1963 await drain(await api(ci, "DELETE", "/v1/repos/alice/doomed"));
1964 const runsDir = `${ci.dataDir}/runs/alice/doomed`;
1965 await assertRejects(() => Deno.stat(runsDir));
1966 } finally {
1967 await ci.stop();
1968 }
1969});
1970
1971// ── M7: import ─────────────────────────────────────────────────────────────
1972
1973live(
1974 "import refuses every URL that could reach the local network",
1975 async (server) => {
1976 for (
1977 const url of [
1978 `http://127.0.0.1:${server.port}/alice/x.git`, // this very server
1979 "http://169.254.169.254/latest/meta-data/", // cloud metadata
1980 "http://localhost/a.git", // a name resolving to loopback
1981 "http://10.0.0.1/a.git",
1982 "http://192.168.1.1/a.git",
1983 "file:///etc/passwd",
1984 "ssh://git@github.com/a/b.git",
1985 "git://github.com/a/b.git",
1986 "https://user:token@github.com/a/b.git", // would persist creds in config
1987 "--upload-pack=evil",
1988 ]
1989 ) {
1990 const response = await api(server, "POST", "/v1/repos", {
1991 body: {
1992 name: `imp${Math.floor(Math.random() * 1e9)}`,
1993 import_url: url,
1994 },
1995 });
1996 const problem = await json(response);
1997 assertEquals(response.status, 400, `${url} must be refused`);
1998 assertStringIncludes(problem.type, "/problems/invalid-import-url");
1999 }
2000 },
2001);
2002
2003live(
2004 "a repository imports with its history and is adopted",
2005 async (_server) => {
2006 // Serve a real bare repository over plain HTTP with git's dumb protocol, so the
2007 // import exercises the actual fetch path rather than only the guards. Importing
2008 // from loopback needs the operator opt-in, which is the point of that setting.
2009 const target = await startWith({ ZUKA_IMPORT_ALLOW_PRIVATE: "1" });
2010 const serveDir = await Deno.makeTempDir({ prefix: "zuka-origin-" });
2011 let httpd: Deno.HttpServer | undefined;
2012
2013 try {
2014 // Build a repository with two commits.
2015 const work = `${serveDir}/work`;
2016 await git(["init", "-q", "--initial-branch=main", work], serveDir);
2017 await Deno.writeTextFile(`${work}/README.md`, "imported\n");
2018 await git(["add", "."], work);
2019 await git(["commit", "-m", "first"], work);
2020 await Deno.writeTextFile(`${work}/second.txt`, "two\n");
2021 await git(["add", "."], work);
2022 await git(["commit", "-m", "second"], work);
2023
2024 // Mirror it and generate the files the dumb protocol needs.
2025 const bare = `${serveDir}/origin.git`;
2026 await git(["clone", "--bare", "-q", work, bare], serveDir);
2027 await git(["update-server-info"], bare);
2028 await git(
2029 ["--git-dir", bare, "config", "http.receivepack", "false"],
2030 serveDir,
2031 );
2032
2033 const port = await freePort();
2034 httpd = Deno.serve(
2035 { port, hostname: "127.0.0.1", onListen: () => {} },
2036 async (req) => {
2037 const path = new URL(req.url).pathname.replace(/^\/origin\.git/, "");
2038 try {
2039 return new Response(await Deno.readFile(`${bare}${path}`));
2040 } catch {
2041 return new Response("not found", { status: 404 });
2042 }
2043 },
2044 );
2045
2046 const created = await api(target, "POST", "/v1/repos", {
2047 body: {
2048 name: "copy",
2049 import_url: `http://127.0.0.1:${port}/origin.git`,
2050 },
2051 });
2052 const view = await json(created);
2053 assertEquals(created.status, 201, JSON.stringify(view));
2054 assertEquals(view.name, "copy");
2055
2056 // History arrived.
2057 const log = await json(
2058 await api(target, "GET", "/v1/repos/alice/copy/commits"),
2059 );
2060 assertEquals(log.items.length, 2, "the full history must be imported");
2061 assertEquals(log.items[0].message, "second");
2062
2063 const readme = await drain(
2064 await api(target, "GET", "/v1/repos/alice/copy/raw/README.md"),
2065 );
2066 assertEquals(readme, "imported\n");
2067
2068 // Adopted: an imported repository must be indistinguishable from one we made.
2069 const gitDir = `${target.dataDir}/git/alice/copy.git`;
2070 for (
2071 const [key, expected] of [["gc.auto", "0"], [
2072 "receive.fsckObjects",
2073 "true",
2074 ]]
2075 ) {
2076 const value = await new Deno.Command("git", {
2077 args: ["--git-dir", gitDir, "config", "--get", key],
2078 stdout: "piped",
2079 stderr: "null",
2080 }).output();
2081 assertEquals(
2082 new TextDecoder().decode(value.stdout).trim(),
2083 expected,
2084 `an imported repository must carry ${key}`,
2085 );
2086 }
2087 const hook = await Deno.lstat(`${gitDir}/hooks/update`);
2088 assert(hook.isSymlink, "an imported repository must get our hooks");
2089
2090 // And it serves: clone the copy back out.
2091 const cloned = await git(
2092 ["clone", cloneUrl(target, "alice", "copy"), "roundtrip"],
2093 target.workDir,
2094 );
2095 assertEquals(cloned.code, 0, cloned.stderr);
2096 assertEquals(
2097 await Deno.readTextFile(`${target.workDir}/roundtrip/second.txt`),
2098 "two\n",
2099 );
2100 } finally {
2101 await httpd?.shutdown();
2102 await target.stop();
2103 await Deno.remove(serveDir, { recursive: true }).catch(() => {});
2104 }
2105 },
2106);
2107
2108live("import can be disabled entirely", async (_server) => {
2109 const off = await startWith({ ZUKA_IMPORT_ENABLED: "0" });
2110 try {
2111 const response = await api(off, "POST", "/v1/repos", {
2112 body: {
2113 name: "nope",
2114 import_url: "https://github.com/rust-lang/rust.git",
2115 },
2116 });
2117 const problem = await json(response);
2118 assertEquals(response.status, 409);
2119 assertStringIncludes(problem.type, "/problems/import-disabled");
2120 } finally {
2121 await off.stop();
2122 }
2123});
2124
2125// ── public repositories and the browser surface ────────────────────────────
2126//
2127// This is the only surface reachable without a credential, so its boundaries are
2128// proven against a real server rather than asserted in a unit test.
2129
2130/** Create a repository and say whether the world may read it. */
2131async function createRepoWithVisibility(
2132 server: Server,
2133 name: string,
2134 visibility: "public" | "private",
2135) {
2136 const response = await api(server, "POST", "/v1/repos", {
2137 body: { name, visibility },
2138 });
2139 const body = await drain(response);
2140 assertEquals(response.status, 201, body);
2141 const repo = JSON.parse(body);
2142 assertEquals(repo.visibility, visibility);
2143 // web_url is the shareable page, so it exists exactly when sharing works: its
2144 // presence is the API's answer to "may I hand this link to a person?".
2145 if (visibility === "public") {
2146 assertEquals(repo.web_url, `http://127.0.0.1:${server.port}/alice/${name}`);
2147 } else {
2148 assertEquals(repo.web_url, undefined);
2149 }
2150}
2151
2152/** Push one commit over HTTP so a repository has something to show. */
2153async function seedOverHttp(server: Server, repo: string, dir: string) {
2154 await Deno.mkdir(dir, { recursive: true });
2155 await git(["init", "-q", "-b", "main"], dir);
2156 await Deno.writeTextFile(`${dir}/README.md`, "# Title\n\nHello.\n");
2157 await git(["add", "-A"], dir);
2158 await git(["commit", "-qm", "first"], dir);
2159 const push = await git(
2160 [
2161 "push",
2162 "-q",
2163 `http://alice:${server.token}@127.0.0.1:${server.port}/alice/${repo}.git`,
2164 "main",
2165 ],
2166 dir,
2167 );
2168 assertEquals(push.code, 0, push.stderr);
2169}
2170
2171live("a public repository can be cloned with no credential", async (server) => {
2172 await createRepoWithVisibility(server, "open", "public");
2173 const work = await Deno.makeTempDir();
2174 await seedOverHttp(server, "open", `${work}/src`);
2175
2176 const cloned = await git(
2177 ["clone", "-q", `http://127.0.0.1:${server.port}/alice/open.git`, "copy"],
2178 work,
2179 );
2180 assertEquals(cloned.code, 0, cloned.stderr);
2181 assertStringIncludes(
2182 await Deno.readTextFile(`${work}/copy/README.md`),
2183 "Hello.",
2184 );
2185});
2186
2187live("a public repository still refuses an anonymous write", async (server) => {
2188 await createRepoWithVisibility(server, "open", "public");
2189 const work = await Deno.makeTempDir();
2190 await seedOverHttp(server, "open", `${work}/src`);
2191
2192 // Asked over fetch rather than through `git push`, because git consults
2193 // credential helpers — on a developer machine the keychain may still hold the
2194 // token used to seed the repository, and the test would then be measuring the
2195 // keychain rather than the service.
2196 //
2197 // Advertising refs for receive-pack is itself the write signal, so this is the
2198 // first point at which an anonymous push is refused.
2199 const advertise = await fetch(
2200 `http://127.0.0.1:${server.port}/alice/open.git/info/refs?service=git-receive-pack`,
2201 );
2202 await drain(advertise);
2203 assertEquals(advertise.status, 401, "public grants read, never write");
2204
2205 const push = await fetch(
2206 `http://127.0.0.1:${server.port}/alice/open.git/git-receive-pack`,
2207 {
2208 method: "POST",
2209 headers: { "content-type": "application/x-git-receive-pack-request" },
2210 body: new Uint8Array([0, 0, 0, 0]),
2211 },
2212 );
2213 await drain(push);
2214 assertEquals(push.status, 401, "the pack endpoint must refuse it too");
2215});
2216
2217live(
2218 "a private repository challenges rather than reporting absence",
2219 async (server) => {
2220 await createRepo(server, "closed");
2221
2222 // 401, not 404: git only runs its credential helper on a challenge, so a 404
2223 // here would fail a legitimate clone as "not found" without ever asking for
2224 // the credential the user had configured.
2225 const response = await fetch(
2226 `http://127.0.0.1:${server.port}/alice/closed.git/info/refs?service=git-upload-pack`,
2227 );
2228 await drain(response);
2229 assertEquals(response.status, 401);
2230
2231 // And a repository that does not exist answers identically, so the status
2232 // code cannot be used to enumerate private names.
2233 const absent = await fetch(
2234 `http://127.0.0.1:${server.port}/alice/no-such-thing.git/info/refs?service=git-upload-pack`,
2235 );
2236 await drain(absent);
2237 assertEquals(absent.status, 401);
2238 },
2239);
2240
2241live("the browser shows a public repository and hides a private one", async (
2242 server,
2243) => {
2244 await createRepoWithVisibility(server, "open", "public");
2245 await createRepo(server, "closed");
2246 const work = await Deno.makeTempDir();
2247 await seedOverHttp(server, "open", `${work}/src`);
2248
2249 const shown = await fetch(`http://127.0.0.1:${server.port}/alice/open`);
2250 const page = await drain(shown);
2251 assertEquals(shown.status, 200);
2252 assertStringIncludes(shown.headers.get("content-type") ?? "", "text/html");
2253 assertStringIncludes(page, "README.md");
2254 assertStringIncludes(page, "Hello.");
2255
2256 // Overview owns the rendered README. Files stays a navigation surface rather
2257 // than making a reader scroll through the same document a second time.
2258 const files = await fetch(
2259 `http://127.0.0.1:${server.port}/alice/open/tree/main/`,
2260 );
2261 const filePage = await drain(files);
2262 assertEquals(files.status, 200, filePage);
2263 assertStringIncludes(filePage, "README.md");
2264 assert(!filePage.includes('<article class="doc">'), filePage);
2265
2266 // A page whose content depends on a credential must never be cached and
2267 // handed to someone else.
2268 assertStringIncludes(shown.headers.get("cache-control") ?? "", "no-store");
2269 // Nothing on this surface runs script, so an injection has nowhere to execute.
2270 assertStringIncludes(
2271 shown.headers.get("content-security-policy") ?? "",
2272 "default-src 'none'",
2273 );
2274
2275 const hidden = await fetch(`http://127.0.0.1:${server.port}/alice/closed`);
2276 await drain(hidden);
2277 assertEquals(hidden.status, 404);
2278});
2279
2280live("an owner's credential still opens their private repository", async (
2281 server,
2282) => {
2283 await createRepo(server, "closed");
2284 const work = await Deno.makeTempDir();
2285 await seedOverHttp(server, "closed", `${work}/src`);
2286
2287 const response = await fetch(
2288 `http://127.0.0.1:${server.port}/alice/closed`,
2289 { headers: { authorization: `Bearer ${server.token}` } },
2290 );
2291 const page = await drain(response);
2292 assertEquals(response.status, 200, page);
2293 assertStringIncludes(page, "private");
2294});
2295
2296live("a rejected credential does not become an anonymous visitor", async (
2297 server,
2298) => {
2299 await createRepoWithVisibility(server, "open", "public");
2300 const work = await Deno.makeTempDir();
2301 await seedOverHttp(server, "open", `${work}/src`);
2302
2303 // Falling back would turn "your token expired" into a success on public
2304 // repositories and a bare 404 on private ones — the least debuggable failure a
2305 // credential can have.
2306 const response = await fetch(`http://127.0.0.1:${server.port}/alice/open`, {
2307 headers: { authorization: "Bearer sk_definitely_not_valid" },
2308 });
2309 await drain(response);
2310 assertEquals(response.status, 401);
2311});
2312
2313live("visibility can be changed after creation and takes effect", async (
2314 server,
2315) => {
2316 await createRepo(server, "later");
2317 const work = await Deno.makeTempDir();
2318 await seedOverHttp(server, "later", `${work}/src`);
2319
2320 const before = await fetch(`http://127.0.0.1:${server.port}/alice/later`);
2321 await drain(before);
2322 assertEquals(before.status, 404);
2323
2324 const patched = await api(server, "PATCH", "/v1/repos/alice/later", {
2325 body: { visibility: "public" },
2326 });
2327 const opened = await drain(patched);
2328 assertEquals(patched.status, 200, opened);
2329 // The moment a repository becomes public, the same response says where it
2330 // lives — an agent should never have to derive the shareable page by string
2331 // surgery on a clone URL.
2332 assertEquals(
2333 JSON.parse(opened).web_url,
2334 `http://127.0.0.1:${server.port}/alice/later`,
2335 );
2336
2337 const after = await fetch(`http://127.0.0.1:${server.port}/alice/later`);
2338 await drain(after);
2339 assertEquals(after.status, 200);
2340
2341 // And back again, because a mistake has to be reversible.
2342 const closed = await api(server, "PATCH", "/v1/repos/alice/later", {
2343 body: { visibility: "private" },
2344 });
2345 const shut = await drain(closed);
2346 assertEquals(closed.status, 200, shut);
2347 // The link is withdrawn with the visibility, so a stale web_url cannot keep
2348 // being handed out by an agent that cached the old response shape.
2349 assertEquals(JSON.parse(shut).web_url, undefined);
2350 const gone = await fetch(`http://127.0.0.1:${server.port}/alice/later`);
2351 await drain(gone);
2352 assertEquals(gone.status, 404);
2353});
2354
2355live("a file can be downloaded from the browser with no credential", async (
2356 server,
2357) => {
2358 await createRepoWithVisibility(server, "open", "public");
2359 const work = await Deno.makeTempDir();
2360 await seedOverHttp(server, "open", `${work}/src`);
2361
2362 // The REST raw endpoint requires a credential; the viewer's Download link uses
2363 // this anonymous route instead.
2364 const response = await fetch(
2365 `http://127.0.0.1:${server.port}/alice/open/raw/main/README.md`,
2366 );
2367 const bytes = await response.text();
2368 assertEquals(response.status, 200);
2369 assertStringIncludes(bytes, "Hello.");
2370 assertStringIncludes(
2371 response.headers.get("content-disposition") ?? "",
2372 "attachment",
2373 );
2374 // Raw bytes are attacker-controlled; they must never render on this origin.
2375 assertStringIncludes(
2376 response.headers.get("content-security-policy") ?? "",
2377 "sandbox",
2378 );
2379});
2380
2381live("a README image is served inline through a raster-only route", async (
2382 server,
2383) => {
2384 await createRepoWithVisibility(server, "images", "public");
2385 const patched = await api(server, "PATCH", "/v1/repos/alice/images", {
2386 body: { description: "A repository with a social card." },
2387 });
2388 assertEquals(patched.status, 200, await drain(patched));
2389 const work = await Deno.makeTempDir();
2390 const dir = `${work}/src`;
2391 await Deno.mkdir(`${dir}/assets`, { recursive: true });
2392 await git(["init", "-q", "-b", "main"], dir);
2393 await Deno.writeTextFile(
2394 `${dir}/README.md`,
2395 "# Picture\n\n![A pixel](assets/og-image.png)\n",
2396 );
2397 // A complete 1x1 PNG. The route must classify it from its extension and serve
2398 // pixels inline; a raw attachment would make every README visual download.
2399 await Deno.writeFile(
2400 `${dir}/assets/og-image.png`,
2401 new Uint8Array([
2402 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
2403 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73,
2404 68, 65, 84, 8, 215, 99, 248, 207, 192, 240, 31, 0, 5, 0, 1, 255, 137,
2405 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
2406 ]),
2407 );
2408 await git(["add", "-A"], dir);
2409 await git(["commit", "-qm", "picture"], dir);
2410 const push = await git(
2411 [
2412 "push",
2413 "-q",
2414 `http://alice:${server.token}@127.0.0.1:${server.port}/alice/images.git`,
2415 "main",
2416 ],
2417 dir,
2418 );
2419 assertEquals(push.code, 0, push.stderr);
2420
2421 const overview = await fetch(`http://127.0.0.1:${server.port}/alice/images`);
2422 const page = await drain(overview);
2423 assertEquals(overview.status, 200, page);
2424 assertStringIncludes(
2425 page,
2426 `/alice/images/media/main/assets/og-image.png`,
2427 );
2428 for (const expected of [
2429 "<title>images — A repository with a social card</title>",
2430 '<meta name="description" content="A repository with a social card.">',
2431 `<link rel="canonical" href="http://127.0.0.1:${server.port}/alice/images">`,
2432 `<meta property="og:image" content="http://127.0.0.1:${server.port}/alice/images/media/main/assets/og-image.png">`,
2433 '<meta property="og:image:width" content="1200">',
2434 '<meta property="og:image:height" content="630">',
2435 '<meta name="twitter:card" content="summary_large_image">',
2436 ]) {
2437 assertStringIncludes(page, expected);
2438 }
2439
2440 const image = await fetch(
2441 `http://127.0.0.1:${server.port}/alice/images/media/main/assets/og-image.png`,
2442 );
2443 await drain(image);
2444 assertEquals(image.status, 200);
2445 assertStringIncludes(image.headers.get("content-type") ?? "", "image/png");
2446 assertEquals(image.headers.get("content-disposition"), null);
2447 assertStringIncludes(
2448 image.headers.get("content-security-policy") ?? "",
2449 "sandbox",
2450 );
2451
2452 // SVG is XML with a browser execution model, so it is deliberately never a
2453 // README media type even if someone commits one under that extension.
2454 const svg = await fetch(
2455 `http://127.0.0.1:${server.port}/alice/images/media/main/assets/og-image.svg`,
2456 );
2457 await drain(svg);
2458 assertEquals(svg.status, 404);
2459});
2460
2461live("a public repository supplies immutable Deno modules", async (server) => {
2462 const landing = await fetch(`http://127.0.0.1:${server.port}/_pkg/`);
2463 const landingHtml = await drain(landing);
2464 assertEquals(landing.status, 200);
2465 assertStringIncludes(
2466 landing.headers.get("content-type") ?? "",
2467 "text/html",
2468 );
2469 for (const expected of [
2470 "<title>The package manager for agents</title>",
2471 '<meta name="description" content="The package manager for agents">',
2472 '<meta property="og:title" content="The package manager for agents">',
2473 '<meta name="twitter:title" content="The package manager for agents">',
2474 "<h1>The package manager for agents</h1>",
2475 ]) {
2476 assertStringIncludes(landingHtml, expected);
2477 }
2478
2479 await createRepoWithVisibility(server, "modules", "public");
2480 const work = await Deno.makeTempDir();
2481 const dir = `${work}/src`;
2482 await Deno.mkdir(`${dir}/lib`, { recursive: true });
2483 await git(["init", "-q", "-b", "main"], dir);
2484 await Deno.writeTextFile(
2485 `${dir}/mod.ts`,
2486 'export { answer } from "./lib/answer.ts";\n',
2487 );
2488 await Deno.writeTextFile(`${dir}/lib/answer.ts`, "export const answer = 42;\n");
2489 await Deno.writeTextFile(`${dir}/README.md`, "# A module\n");
2490 await git(["add", "-A"], dir);
2491 await git(["commit", "-qm", "package release"], dir);
2492 const sha = (await git(["rev-parse", "HEAD"], dir)).stdout.trim();
2493 const blob = (await git(["hash-object", "mod.ts"], dir)).stdout.trim();
2494 const remote =
2495 `http://alice:${server.token}@127.0.0.1:${server.port}/alice/modules.git`;
2496 const added = await git(["remote", "add", "origin", remote], dir);
2497 assertEquals(added.code, 0, added.stderr);
2498 const push = await git(["push", "-q", "origin", "main"], dir);
2499 assertEquals(push.code, 0, push.stderr);
2500
2501 // A Git-native release: `pkg/0.0.1` is the one tag namespace zuka makes
2502 // permanent, and the public URL uses the human version rather than exposing
2503 // the commit id a person should not have to remember.
2504 const tag = await git(["tag", "pkg/0.0.1"], dir);
2505 assertEquals(tag.code, 0, tag.stderr);
2506 const tagged = await git(["push", "-q", "origin", "pkg/0.0.1"], dir);
2507 assertEquals(tagged.code, 0, tagged.stderr);
2508
2509 const base = `http://127.0.0.1:${server.port}/_pkg/alice/modules@0.0.1`;
2510 const response = await fetch(`${base}/mod.ts`);
2511 const source = await response.text();
2512 assertEquals(response.status, 200, source);
2513 assertStringIncludes(
2514 response.headers.get("content-type") ?? "",
2515 "application/typescript",
2516 );
2517 assertStringIncludes(
2518 response.headers.get("cache-control") ?? "",
2519 "immutable",
2520 );
2521 assertEquals(response.headers.get("content-disposition"), null);
2522 assertEquals(response.headers.get("access-control-allow-origin"), "*");
2523 assertStringIncludes(
2524 response.headers.get("content-security-policy") ?? "",
2525 "default-src 'none'",
2526 );
2527
2528 // A fresh cache proves the module's relative import stays beneath the same
2529 // commit prefix and Deno accepts the explicit module MIME type.
2530 const imported = await new Deno.Command("deno", {
2531 args: [
2532 "eval",
2533 "--no-lock",
2534 `const module = await import(${JSON.stringify(`${base}/mod.ts`)}); console.log(module.answer);`,
2535 ],
2536 env: { ...Deno.env.toObject(), DENO_DIR: `${work}/deno-cache` },
2537 stdout: "piped",
2538 stderr: "piped",
2539 }).output();
2540 const output = new TextDecoder().decode(imported.stdout);
2541 assert(imported.success, new TextDecoder().decode(imported.stderr));
2542 assertEquals(output.trim(), "42");
2543
2544 // The source SHA remains a useful provenance URL, but the version is the
2545 // import a person writes.
2546 const bySha = await fetch(
2547 `http://127.0.0.1:${server.port}/_pkg/alice/modules@${sha}/mod.ts`,
2548 );
2549 await drain(bySha);
2550 assertEquals(bySha.status, 200);
2551
2552 // Version tags cannot be rewritten or deleted after their first push. The
2553 // tag is the permanent Git anchor that makes year-long module caching honest.
2554 await Deno.writeTextFile(`${dir}/lib/answer.ts`, "export const answer = 43;\n");
2555 await git(["add", "-A"], dir);
2556 await git(["commit", "-qm", "a later answer"], dir);
2557 const moved = await git(["tag", "-f", "pkg/0.0.1"], dir);
2558 assertEquals(moved.code, 0, moved.stderr);
2559 const force = await git(["push", "--force", "origin", "pkg/0.0.1"], dir);
2560 assert(force.code !== 0, force.stderr);
2561 const deleted = await git(["push", "origin", ":refs/tags/pkg/0.0.1"], dir);
2562 assert(deleted.code !== 0, deleted.stderr);
2563
2564 // A package revision is a full commit, never a mutable branch, short id or a
2565 // Git blob that merely happens to have forty hexadecimal characters.
2566 for (const invalid of [
2567 `http://127.0.0.1:${server.port}/_pkg/alice/modules@main/mod.ts`,
2568 `http://127.0.0.1:${server.port}/_pkg/alice/modules@${sha.slice(0, 12)}/mod.ts`,
2569 `http://127.0.0.1:${server.port}/_pkg/alice/modules@${blob}/mod.ts`,
2570 `http://127.0.0.1:${server.port}/_pkg/alice/modules@v0.0.1/mod.ts`,
2571 `${base}/README.md`,
2572 `${base}/mod.ts?cache=split`,
2573 ]) {
2574 const rejected = await fetch(invalid);
2575 await drain(rejected);
2576 assertEquals(rejected.status, 404, invalid);
2577 }
2578
2579 await createRepo(server, "private-package");
2580 const privateModule = await fetch(
2581 `http://127.0.0.1:${server.port}/_pkg/alice/private-package@${sha}/mod.ts`,
2582 );
2583 await drain(privateModule);
2584 assertEquals(privateModule.status, 404);
2585});
2586
2587live("every repository answers llms.txt, written or generated", async (
2588 server,
2589) => {
2590 await createRepoWithVisibility(server, "open", "public");
2591 const work = await Deno.makeTempDir();
2592 await seedOverHttp(server, "open", `${work}/src`);
2593
2594 // No llms.txt committed: a default is generated, because on an agent-first
2595 // host "no file" must not mean "no front door".
2596 const generated = await fetch(
2597 `http://127.0.0.1:${server.port}/alice/open/llms.txt`,
2598 );
2599 const text = await generated.text();
2600 assertEquals(generated.status, 200);
2601 assertStringIncludes(generated.headers.get("content-type") ?? "", "text/plain");
2602 assertStringIncludes(text, "# open");
2603 assertStringIncludes(text, "git clone");
2604 assertStringIncludes(text, "README.md");
2605 assertStringIncludes(text, "/mcp");
2606
2607 // A committed llms.txt is the author speaking and is served verbatim.
2608 await createRepoWithVisibility(server, "doors", "public");
2609 const dir = `${work}/doors`;
2610 await Deno.mkdir(dir, { recursive: true });
2611 await git(["init", "-q", "-b", "main"], dir);
2612 await Deno.writeTextFile(`${dir}/llms.txt`, "# custom door\n");
2613 await git(["add", "-A"], dir);
2614 await git(["commit", "-qm", "door"], dir);
2615 const push = await git(
2616 [
2617 "push",
2618 "-q",
2619 `http://alice:${server.token}@127.0.0.1:${server.port}/alice/doors.git`,
2620 "main",
2621 ],
2622 dir,
2623 );
2624 assertEquals(push.code, 0, push.stderr);
2625 const custom = await fetch(
2626 `http://127.0.0.1:${server.port}/alice/doors/llms.txt`,
2627 );
2628 assertEquals(await custom.text(), "# custom door\n");
2629
2630 // The root follows the home-repo rule; unset here, so the service answers
2631 // for itself.
2632 const root = await fetch(`http://127.0.0.1:${server.port}/llms.txt`);
2633 const rootText = await root.text();
2634 assertEquals(root.status, 200);
2635 assertStringIncludes(rootText, "POST /mcp");
2636
2637 // And the page rule still holds: a private repository's front door is not
2638 // readable anonymously.
2639 await createRepo(server, "shut");
2640 const hidden = await fetch(
2641 `http://127.0.0.1:${server.port}/alice/shut/llms.txt`,
2642 );
2643 await drain(hidden);
2644 assertEquals(hidden.status, 404);
2645});
2646
2647live("a missing page is a readable page, not machine json", async (server) => {
2648 // A person tapping a dead link in a chat gets a sentence and a way home; the
2649 // status still says 404 so nothing can probe private names through the copy.
2650 const response = await fetch(
2651 `http://127.0.0.1:${server.port}/alice/no-such-project`,
2652 );
2653 const page = await drain(response);
2654 assertEquals(response.status, 404);
2655 assertStringIncludes(response.headers.get("content-type") ?? "", "text/html");
2656 assertStringIncludes(page, "nothing at this address");
2657});
2658
2659live("a README cannot smuggle script into the page", async (server) => {
2660 await createRepoWithVisibility(server, "hostile", "public");
2661 const work = await Deno.makeTempDir();
2662 const dir = `${work}/src`;
2663 await Deno.mkdir(dir, { recursive: true });
2664 await git(["init", "-q", "-b", "main"], dir);
2665 await Deno.writeTextFile(
2666 `${dir}/README.md`,
2667 "# T\n\n<script>alert(1)</script>\n\n[x](javascript:alert(2))\n\n<img src=x onerror=alert(3)>\n",
2668 );
2669 await git(["add", "-A"], dir);
2670 await git(["commit", "-qm", "hostile"], dir);
2671 await git(
2672 [
2673 "push",
2674 "-q",
2675 `http://alice:${server.token}@127.0.0.1:${server.port}/alice/hostile.git`,
2676 "main",
2677 ],
2678 dir,
2679 );
2680
2681 const page = await drain(
2682 await fetch(`http://127.0.0.1:${server.port}/alice/hostile`),
2683 );
2684 // Anyone who can push writes this file, and on a public repository that is a
2685 // stranger.
2686 assert(!page.includes("<script"), "raw script survived into the page");
2687 assert(!page.includes("onerror="), "an event handler survived into the page");
2688 assert(
2689 !page.toLowerCase().includes("href=\"javascript:"),
2690 "an executable href survived into the page",
2691 );
2692});