/** * @file Tests for Add workspace → Clone and a user's own git identity: * `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that * becomes a workspace, the SSH key persisted into the clone, 409 * ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once, * only the public half returned, 0600 on disk), and the member (S3) path of * `server/lib/git-identity.js` with run-as and git stubbed — what reaches * git's argv, and that a login typed without "remember" leaves the member's * store as it was. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const http = require("node:http"); const { execFileSync } = require("node:child_process"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-clone-route-")); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db"); process.env.CLAUDE_HOME = path.join(ROOT, ".claude"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.GIT_CONFIG_NOSYSTEM = "1"; process.env.HOME = ROOT; const { createApp } = require("../index"); const identity = require("../lib/git-identity"); let app; let remote; let base; const WORK = path.join(ROOT, "work"); function request(method, p, body, headers = {}) { return fetch(`${base}${p}`, { method, headers: { "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); } before(async () => { // A local bare repo stands in for the forge: the test's own ~/.gitconfig // rewrites the https and scp-like URLs to it (git reads HOME's config). const src = path.join(ROOT, "src"); const bare = path.join(ROOT, "bare.git"); execFileSync("git", ["init", "-q", "-b", "main", src]); fs.writeFileSync(path.join(src, "README.md"), "hi\n"); execFileSync("git", ["-C", src, "add", "."]); execFileSync("git", [ "-C", src, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init", ]); execFileSync("git", ["clone", "-q", "--bare", src, bare]); remote = http.createServer((_req, res) => { res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' }); res.end(); }); await new Promise((r) => remote.listen(0, "127.0.0.1", r)); const authHost = `127.0.0.1:${remote.address().port}`; fs.writeFileSync( path.join(ROOT, ".gitconfig"), [ `[url "file://${bare}"]`, "\tinsteadOf = https://forge.test/o/r.git", "\tinsteadOf = git@forge.test:o/r.git", `[url "file://${path.join(ROOT, "missing.git")}"]`, "\tinsteadOf = git@forge.test:o/missing.git", `[url "http://${authHost}/"]`, "\tinsteadOf = https://private.test/", "", ].join("\n") ); fs.mkdirSync(WORK); app = http.createServer(createApp()); await new Promise((r) => app.listen(0, "127.0.0.1", r)); base = `http://127.0.0.1:${app.address().port}`; }); after(async () => { await new Promise((r) => app.close(r)); await new Promise((r) => remote.close(r)); }); describe("POST /api/lanes/clone", () => { it("refuses a URL outside the allowlist", async () => { for (const url of [ "file:///etc", "http://h/x", "https://u:p@h/x", "-oProxyCommand=x", "ext::sh", ]) { const r = await request("POST", "/api/lanes/clone", { url, parent: WORK }); assert.equal(r.status, 400, url); assert.equal(r.body.error.code, "EBADURL", url); } }); it("refuses a name with a separator and a missing parent", async () => { const bad = await request("POST", "/api/lanes/clone", { url: "https://forge.test/o/r.git", parent: WORK, name: "a/b", }); assert.equal(bad.body.error.code, "EBADNAME"); const gone = await request("POST", "/api/lanes/clone", { url: "https://forge.test/o/r.git", parent: path.join(ROOT, "nope"), }); assert.equal(gone.body.error.code, "ENOTFOUND"); }); it("clones over https into / and makes it a workspace", async () => { const r = await request("POST", "/api/lanes/clone", { url: "https://forge.test/o/r.git", parent: WORK, tool: "claude", }); assert.equal(r.status, 201, JSON.stringify(r.body)); assert.equal(r.body.lane.title, "r"); assert.equal(fs.readFileSync(path.join(WORK, "r", "README.md"), "utf8"), "hi\n"); const again = await request("POST", "/api/lanes/clone", { url: "https://forge.test/o/r.git", parent: WORK, }); assert.equal(again.status, 409); assert.equal(again.body.error.code, "EEXISTS"); }); it("clones over ssh with the user's key, kept in the clone's config", async () => { const made = await request("POST", "/api/me/git/ssh-key", {}); assert.equal(made.status, 201); const r = await request("POST", "/api/lanes/clone", { url: "git@forge.test:o/r.git", parent: WORK, name: "via-ssh", }); assert.equal(r.status, 201, JSON.stringify(r.body)); const cmd = execFileSync( "git", ["-C", path.join(WORK, "via-ssh"), "config", "core.sshCommand"], { encoding: "utf8", } ); assert.match(cmd, /agenthub-ssh\/id_ed25519/); assert.match(cmd, /IdentitiesOnly=yes/); }); it("answers 409 ECREDENTIALS with the host when the remote wants a login", async () => { const r = await request("POST", "/api/lanes/clone", { url: "https://private.test/o/r.git", parent: WORK, name: "private", }); assert.equal(r.status, 409, JSON.stringify(r.body)); assert.equal(r.body.error.code, "ECREDENTIALS"); assert.equal(r.body.error.auth.host, "private.test"); assert.ok(!fs.existsSync(path.join(WORK, "private"))); }); it("says a missing repository is missing, not that the SSH key was refused", async () => { const r = await request("POST", "/api/lanes/clone", { url: "git@forge.test:o/missing.git", parent: WORK, name: "missing", }); assert.equal(r.status, 409, JSON.stringify(r.body)); assert.equal(r.body.error.code, "ENOREPO"); }); it("refuses a cross-origin request", async () => { const r = await request( "POST", "/api/lanes/clone", { url: "https://forge.test/o/r.git", parent: WORK, name: "x" }, { Origin: "https://evil.example" } ); assert.equal(r.status, 403); }); }); describe("POST /api/lanes/:id/sources/clone", () => { it("clones beside the workspace folder and adds it as an extra folder", async () => { const home = path.join(WORK, "app"); fs.mkdirSync(home); const made = await request("POST", "/api/lanes/ensure", { cwd: home }); assert.ok([200, 201].includes(made.status), JSON.stringify(made.body)); const id = made.body.lane.id; const r = await request("POST", `/api/lanes/${id}/sources/clone`, { url: "https://forge.test/o/r.git", name: "lib", // Ignored: the clone always lands beside the workspace folder. parent: path.join(ROOT, "elsewhere"), }); assert.equal(r.status, 201, JSON.stringify(r.body)); const target = path.join(WORK, "lib"); assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target)); assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n"); const folders = r.body.sources.filter((s) => s.kind === "folder"); assert.deepEqual( folders.map((f) => fs.realpathSync(f.path)), [fs.realpathSync(target)] ); const again = await request("POST", `/api/lanes/${id}/sources/clone`, { url: "https://forge.test/o/r.git", name: "lib", }); assert.equal(again.status, 409); assert.equal(again.body.error.code, "EEXISTS"); const login = await request("POST", `/api/lanes/${id}/sources/clone`, { url: "https://private.test/o/r.git", name: "private-lib", }); assert.equal(login.status, 409); assert.equal(login.body.error.code, "ECREDENTIALS"); assert.equal(login.body.error.auth.host, "private.test"); }); it("answers 404 for a workspace that does not exist", async () => { const r = await request("POST", "/api/lanes/99999/sources/clone", { url: "https://forge.test/o/r.git", }); assert.equal(r.status, 404); }); }); describe("/api/me/git", () => { it("makes a key once, returns only its public half, and deletes it", async () => { await request("DELETE", "/api/me/git/ssh-key"); const made = await request("POST", "/api/me/git/ssh-key", {}); assert.equal(made.status, 201); assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /); assert.match(made.body.ssh.fingerprint, /^SHA256:/); assert.ok(!JSON.stringify(made.body).includes("PRIVATE")); const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519"); assert.equal(fs.statSync(key).mode & 0o777, 0o600); // ssh-keygen reads it back to the same public key. const pub = execFileSync("ssh-keygen", ["-y", "-f", key], { encoding: "utf8" }).trim(); assert.equal( pub.split(" ").slice(0, 2).join(" "), made.body.ssh.publicKey.split(" ").slice(0, 2).join(" ") ); const twice = await request("POST", "/api/me/git/ssh-key", {}); assert.equal(twice.status, 409); const replaced = await request("POST", "/api/me/git/ssh-key", { replace: true }); assert.equal(replaced.status, 201); assert.notEqual(replaced.body.ssh.publicKey, made.body.ssh.publicKey); const del = await request("DELETE", "/api/me/git/ssh-key"); assert.equal(del.body.ssh, null); }); it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => { await request("POST", "/api/me/git/ssh-key", {}); const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" }); assert.equal(rsa.status, 201, JSON.stringify(rsa.body)); assert.equal(rsa.body.ssh.type, "rsa"); assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/); assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE")); const dir = path.join(ROOT, ".claude", "agenthub-ssh"); assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600); assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced"); const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], { encoding: "utf8", }); assert.match(bits, /^4096 .*\(RSA\)/); assert.equal( (await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status, 201 ); // An unknown type falls back to the default (the route only passes rsa through). assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519"); await request("DELETE", "/api/me/git/ssh-key"); }); it("reads and sets the commit author, refusing a bad one", async () => { const r0 = await request("GET", "/api/me/git"); assert.deepEqual(r0.body.author, { name: null, email: null }); const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" }); assert.equal(bad.status, 400); assert.equal(bad.body.error.code, "EBADAUTHOR"); const ok = await request("PUT", "/api/me/git/author", { name: "Vi", email: "vi@example.com" }); assert.equal(ok.status, 200); assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" }); assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/); }); it("saves an https login without ever returning the token", async () => { const r = await request("POST", "/api/me/git/https", { host: "git.example.test", username: "vi", password: "tok-secret", }); assert.equal(r.status, 200); assert.equal(r.body.account, "host"); assert.equal(r.body.https[0].host, "git.example.test"); assert.ok(!JSON.stringify(r.body).includes("tok-secret")); const del = await request("DELETE", "/api/me/git/https/git.example.test"); assert.deepEqual(del.body.https, []); }); }); describe("git-identity, member account (S3, stubbed)", () => { const runAs = require("../lib/run-as"); const worktree = require("../lib/worktree"); const HOME = path.join(ROOT, "users", "an"); // This process's own uid: the key is checked to be the member's own file. const acct = { name: "an", login: "ah-an", home: HOME, uid: process.getuid() }; const saved = {}; let calls = []; before(() => { fs.mkdirSync(path.join(HOME, "proj"), { recursive: true }); for (const k of ["accountForPath", "readFileSafe", "mkdirAs", "writeAs", "removeAs"]) { saved[k] = runAs[k]; } saved.git = worktree.git; const inHome = (p) => path.resolve(p).startsWith(HOME); runAs.accountForPath = (p) => (inHome(p) ? acct : null); runAs.readFileSafe = (f, enc) => fs.readFileSync(f, enc); runAs.mkdirAs = (_a, d) => fs.mkdirSync(d, { recursive: true }); runAs.writeAs = (_a, f, mode, content) => { if (mode === "remove") return fs.rmSync(f, { recursive: true, force: true }); fs.writeFileSync(f, content, { mode: mode === "private" ? 0o600 : 0o644 }); }; runAs.removeAs = (_a, f) => fs.rmSync(f, { recursive: true, force: true }); worktree.git = async (cwd, args) => { calls.push({ cwd, args }); return { stdout: "", stderr: "" }; }; }); after(() => { for (const [k, v] of Object.entries(saved)) { if (k === "git") worktree.git = v; else runAs[k] = v; } }); it("keeps the key and the logins in the member's own home", async () => { const account = identity.accountForDir(path.join(HOME, "proj")); assert.equal(account.kind, "member"); await identity.generateSshKey(account); const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519"); assert.equal(fs.statSync(key).mode & 0o777, 0o600); identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" }); const store = path.join(HOME, ".config", "agenthub", "git-credentials"); assert.equal(fs.statSync(store).mode & 0o777, 0o600); assert.equal(fs.readFileSync(store, "utf8"), "https://an:p%40ss%2F1@gitlab.test\n"); assert.deepEqual(identity.listHttps(account), [ { protocol: "https", host: "gitlab.test", username: "an", hasToken: true }, ]); }); it("clones over ssh with the member's key and persists it", async () => { calls = []; await identity.cloneRepo({ url: "git@gh.test:o/r.git", parent: path.join(HOME, "proj"), name: "r", }); const { args } = calls[0]; const i = args.indexOf("clone"); assert.match( args[i - 1], /^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/ ); assert.match(args[i + 2], /^core\.sshCommand=ssh -i /); assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]); }); it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => { const account = identity.accountForDir(path.join(HOME, "proj")); const store = path.join(HOME, ".config", "agenthub", "git-credentials"); const before = fs.readFileSync(store, "utf8"); const hosts = [{ protocol: "https", host: "gitlab.test" }]; const saved = identity.memberHttpsConfig(account, hosts, null); assert.match(saved.args.join(" "), /credential\.helper=store --file=/); assert.deepEqual(saved.stored("https", "gitlab.test"), { username: "an" }); assert.throws( () => identity.memberHttpsConfig(account, hosts, { host: "evil.test", username: "u", password: "p", }), { code: "EBADHOST" } ); const typed = identity.memberHttpsConfig(account, hosts, { host: "gitlab.test", username: "an", password: "once-only", }); assert.ok(!typed.args.join(" ").includes("once-only")); assert.match(fs.readFileSync(store, "utf8"), /once-only/); typed.finish(true); // worked, but not remembered assert.equal(fs.readFileSync(store, "utf8"), before); }); it("refuses to rewrite a store it cannot read rather than dropping every login", () => { const account = identity.accountForDir(path.join(HOME, "proj")); const read = runAs.readFileSafe; runAs.readFileSafe = () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); }; try { assert.throws( () => identity.saveHttps(account, { host: "x.test", username: "u", password: "p" }), { code: "ESTOREUNREADABLE", } ); } finally { runAs.readFileSafe = read; } }); it("uses a typed https login through the member's store, never argv, and forgets it", async () => { calls = []; const store = path.join(HOME, ".config", "agenthub", "git-credentials"); const before = fs.readFileSync(store, "utf8"); worktree.git = async (cwd, args) => { calls.push({ cwd, args, during: fs.readFileSync(store, "utf8") }); return { stdout: "", stderr: "" }; }; await identity.cloneRepo({ url: "https://new.test/o/r.git", parent: path.join(HOME, "proj"), name: "r2", once: { username: "an", password: "typed-secret", remember: false }, }); const { args, during } = calls[0]; assert.ok(!args.join(" ").includes("typed-secret")); assert.ok(args.some((a) => /^credential\.helper=store --file=/.test(a))); assert.match(during, /typed-secret@new\.test/); // Not remembered: the store is back as it was, and nothing is persisted. assert.equal(fs.readFileSync(store, "utf8"), before); assert.equal(args.slice(args.indexOf("clone") + 1).filter((a) => a === "-c").length, 0); }); });