Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

WIP: HTTP helpers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 5b42824commit 2199d7c3b4986b9ded26327c3e22124b543b313dBrowse files
3 files changed+131−1
addedapps/diffinery/src/http/common.ts+35−0View file
@@ -1,0 +1,35 @@
1+import type { Env } from "../env.js";
2+import type { Repo } from "../lib/directory.js";
3+import { HttpError } from "../lib/util.js";
4+import { callerFor, type AppContext } from "../auth/principal.js";
5+import { atLeast, type Caller, type Role } from "../repo/types.js";
6+
7+export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId));
8+
9+/** Load a repository and the caller's role, enforcing a minimum role. 404 hides private repos. */
10+export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read"): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub> }> {
11+ const repo = await c.get("dir").repo(owner, name.replace(/\.git$/, ""));
12+ if (!repo) throw new HttpError(404, "repository not found");
13+ const caller = await callerFor(c, repo);
14+ if (!caller) {
15+ if (c.get("principal").type === "anonymous") throw new HttpError(401, "authentication required");
16+ throw new HttpError(404, "repository not found");
17+ }
18+ if (!atLeast(caller.role, min)) throw new HttpError(403, `${min} access required`);
19+ if (repo.archived && min !== "read" && min !== "admin") throw new HttpError(403, "repository is archived (read-only)");
20+ return { repo, caller, stub: repoStub(c.env, repo.id) };
21+}
22+
23+export function errorResponse(e: unknown): Response {
24+ if (e instanceof HttpError) {
25+ const headers: Record<string, string> = { "content-type": "application/json" };
26+ if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery", charset="UTF-8"';
27+ return new Response(JSON.stringify({ message: e.detail, ...e.extra }), { status: e.status, headers });
28+ }
29+ // Errors thrown inside Durable Objects arrive as plain Errors; recover HttpError-like status when present.
30+ const msg = (e as Error)?.message ?? String(e);
31+ const remote = /^(\d{3}):/.exec(msg);
32+ if (remote) return new Response(JSON.stringify({ message: msg.slice(5) }), { status: Number(remote[1]), headers: { "content-type": "application/json" } });
33+ console.error(e);
34+ return new Response(JSON.stringify({ message: "internal error" }), { status: 500, headers: { "content-type": "application/json" } });
35+}
modifiedapps/diffinery/src/lib/util.ts+5−1View file
@@ -57,8 +57,12 @@
5757 }
5858
5959 export class HttpError extends Error {
60+ /** The message without the status prefix. */
61+ readonly detail: string;
6062 constructor(public status: number, message: string, public extra?: Record<string, unknown>) {
61- super(message);
63+ // The status is part of the message so it survives Durable Object RPC boundaries.
64+ super(`${status}: ${message}`);
65+ this.detail = message;
6266 }
6367 }
6468
addedcontainers/runner/test/sandbox.test.ts+91−0View file
@@ -1,0 +1,91 @@
1+import { execFileSync } from "node:child_process";
2+import { readFileSync, rmSync, writeFileSync } from "node:fs";
3+import path from "node:path";
4+import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+import { startSandbox, type SandboxHandle } from "../src/sandbox.js";
6+import { tmp } from "./helpers.js";
7+
8+const SECRET = "sandbox-secret-xyz";
9+const TOKEN = "job-token-abc-123";
10+let sb: SandboxHandle;
11+let workRoot: string;
12+let origin: string;
13+let base: string;
14+
15+async function call(p: string, body?: unknown, auth = SECRET) {
16+ const res = await fetch(`${base}${p}`, {
17+ method: body === undefined ? "GET" : "POST",
18+ headers: { authorization: `Bearer ${auth}`, "content-type": "application/json" },
19+ body: body === undefined ? undefined : JSON.stringify(body),
20+ });
21+ return { status: res.status, json: (await res.json()) as Record<string, any> };
22+}
23+
24+beforeAll(async () => {
25+ workRoot = tmp("sandbox-");
26+ origin = tmp("sandbox-origin-");
27+ const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
28+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: origin, env });
29+ writeFileSync(path.join(origin, "hello.txt"), "hello sandbox\n");
30+ execFileSync("git", ["add", "."], { cwd: origin, env });
31+ execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: origin, env });
32+ execFileSync("git", ["checkout", "-q", "-b", "feature"], { cwd: origin, env });
33+ writeFileSync(path.join(origin, "feature.txt"), "on feature\n");
34+ execFileSync("git", ["add", "."], { cwd: origin, env });
35+ execFileSync("git", ["commit", "-q", "-m", "feat"], { cwd: origin, env });
36+ execFileSync("git", ["checkout", "-q", "main"], { cwd: origin, env });
37+ sb = await startSandbox({ port: 0, host: "127.0.0.1", secret: SECRET, workRoot, cloneUrl: `file://${origin}`, ref: "feature", token: TOKEN, maxOutputBytes: 1000 });
38+ base = `http://127.0.0.1:${sb.port}`;
39+ await sb.ready;
40+});
41+
42+afterAll(async () => {
43+ await sb?.close();
44+ rmSync(workRoot, { recursive: true, force: true });
45+ rmSync(origin, { recursive: true, force: true });
46+});
47+
48+describe("sandbox server", () => {
49+ it("reports health without auth and rejects unauthenticated calls", async () => {
50+ const h = await fetch(`${base}/health`);
51+ expect(await h.json()).toEqual({ ok: true, ready: true });
52+ expect((await call("/exec", { cmd: "true" }, "wrong")).status).toBe(401);
53+ const noAuth = await fetch(`${base}/exec`, { method: "POST", body: "{}" });
54+ expect(noAuth.status).toBe(401);
55+ });
56+
57+ it("cloned the repo at the requested ref", async () => {
58+ const r = await call("/exec", { cmd: "git rev-parse --abbrev-ref HEAD && cat hello.txt feature.txt" });
59+ expect(r.status).toBe(200);
60+ expect(r.json).toMatchObject({ exitCode: 0, stdout: "feature\nhello sandbox\non feature\n", stderr: "" });
61+ });
62+
63+ it("executes commands with cwd/env, masks secrets and truncates output", async () => {
64+ await call("/exec", { cmd: "mkdir -p sub" });
65+ const r = await call("/exec", { cmd: 'pwd; echo "v=$MYVAR"; echo "s=$DIFFINERY_SANDBOX_SECRET"; echo err >&2; echo "tok=' + TOKEN + '"; exit 3', cwd: "sub", env: { MYVAR: "x1" } });
66+ expect(r.json.exitCode).toBe(3);
67+ expect(r.json.stdout).toBe(`${path.join(sb.repoDir, "sub")}\nv=x1\ns=\ntok=***\n`);
68+ expect(r.json.stderr).toBe("err\n");
69+ const big = await call("/exec", { cmd: "head -c 5000 /dev/zero | tr '\\0' a" });
70+ expect(big.json.stdout.length).toBe(1000);
71+ expect(big.json.truncated).toBe(true);
72+ });
73+
74+ it("times out long commands", async () => {
75+ const r = await call("/exec", { cmd: "sleep 20", timeoutMs: 300 });
76+ expect(r.json.timedOut).toBe(true);
77+ expect(r.json.exitCode).toBe(124);
78+ });
79+
80+ it("reads and writes files (base64)", async () => {
81+ const content = Buffer.from("bin\0ary ✓").toString("base64");
82+ const w = await call("/files/write", { path: "new/dir/file.bin", content });
83+ expect(w.json).toMatchObject({ ok: true, size: Buffer.from("bin\0ary ✓").length });
84+ expect(readFileSync(path.join(sb.repoDir, "new/dir/file.bin"), "utf8")).toBe("bin\0ary ✓");
85+ const r = await call("/files/read", { path: "new/dir/file.bin" });
86+ expect(Buffer.from(r.json.content, "base64").toString()).toBe("bin\0ary ✓");
87+ expect((await call("/files/read", { path: "missing.txt" })).status).toBe(404);
88+ expect((await call("/files/read", {})).status).toBe(400);
89+ expect((await call("/nope", {})).status).toBe(404);
90+ });
91+});