feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer dies on "terminal prompts disabled": the server answers 409 EGITAUTH with the host, the git bar asks for a username + password/access token and resends the same action. AgentHub keeps its OWN credential store (<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host, shared by every user of the install, managed from Settings -> Git credentials. A login is only sent to one of the repo's own remote hosts, saved only after git accepted it, never returned by any route, and reaches git through an exclusive credential helper (never argv). Part B — merge a branch (or the diverged upstream) from the git bar; a merge that stops on conflicts lists them in the Changes panel with Keep mine / Take theirs / Mark resolved, then Complete merge or Abort merge (confirm gate). Clean tree, repo-root lane and an existing ref only; pull/switch/hunk actions are blocked mid-merge; staging a conflicted file with markers left is refused. Rebase/reset/stash stay out.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Route tests for git credentials (issue #45): /api/git-credentials
|
||||
* never returns a password and refuses a cross-origin write, and a lane's
|
||||
* fetch against a remote that wants a login answers 409 EGITAUTH with the
|
||||
* host the git bar's login form needs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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-creds-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");
|
||||
|
||||
let app;
|
||||
let remote;
|
||||
let base;
|
||||
let host;
|
||||
|
||||
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 () => {
|
||||
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));
|
||||
host = `127.0.0.1:${remote.address().port}`;
|
||||
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("/api/git-credentials", () => {
|
||||
it("saves, lists without the secret, and deletes", async () => {
|
||||
const saved = await request("POST", "/api/git-credentials", {
|
||||
protocol: "https",
|
||||
host: "git.example.test",
|
||||
username: "vi",
|
||||
password: "super-secret-token",
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
const list = await request("GET", "/api/git-credentials");
|
||||
assert.equal(list.body.items[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list.body).includes("super-secret-token"));
|
||||
const del = await request("DELETE", "/api/git-credentials/https/git.example.test");
|
||||
assert.deepEqual(del.body.items, []);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
"/api/git-credentials",
|
||||
{ protocol: "https", host: "h.test", username: "u", password: "p" },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/:id/git/fetch", () => {
|
||||
it("answers 409 EGITAUTH with the host when the remote wants a login", async () => {
|
||||
const repo = path.join(ROOT, "repo");
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
const lane = await request("POST", "/api/lanes/ensure", { cwd: repo, tool: "claude" });
|
||||
const r = await request("POST", `/api/lanes/${lane.body.lane.id}/git/fetch`, {});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "EGITAUTH");
|
||||
assert.equal(r.body.error.auth.host, host);
|
||||
assert.equal(r.body.error.auth.rejected, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file Tests for AgentHub's own git credential store and the credential
|
||||
* helper that hands it to git: save/list/remove at 0600, a list that never
|
||||
* carries the secret, auth-failure detection, and a REAL `git credential fill`
|
||||
* run through the helper — stored and use-once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = 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 { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
function fill(dir, cfg, env = {}) {
|
||||
// Exactly what git-ops passes, then git's own credential lookup.
|
||||
const out = execFileSync("git", [...cfg.args, "credential", "fill"], {
|
||||
cwd: dir,
|
||||
input: "protocol=https\nhost=git.example.test\n\n",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...cfg.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
return Object.fromEntries(
|
||||
out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)])
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => fs.rmSync(path.join(ROOT, ".claude"), { recursive: true, force: true }));
|
||||
|
||||
describe("git credential store", () => {
|
||||
it("saves one record per protocol+host at 0600 and never lists the secret", () => {
|
||||
creds.save({ protocol: "https", host: "Git.Example.Test", username: "a", password: "t1" });
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "b", password: "t2" });
|
||||
const list = creds.list();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].host, "git.example.test");
|
||||
assert.equal(list[0].username, "b");
|
||||
assert.equal(list[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list).includes("t2"));
|
||||
assert.equal(fs.statSync(creds.credentialsFile()).mode & 0o777, 0o600);
|
||||
assert.equal(creds.remove("https", "git.example.test"), true);
|
||||
assert.deepEqual(creds.list(), []);
|
||||
});
|
||||
|
||||
it("refuses a host that is not a host", () => {
|
||||
for (const host of ["", "a/b", "x y", "../etc", "h\nost"]) {
|
||||
assert.throws(() => creds.save({ protocol: "https", host, username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
}
|
||||
assert.throws(() => creds.save({ protocol: "ssh", host: "h", username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises git's auth failures and names the host", () => {
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled"
|
||||
),
|
||||
{ protocol: "https", host: "git.smartgift.io.vn" }
|
||||
);
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"remote: Invalid username or password.\nfatal: Authentication failed for 'http://h.local:3000/o/r.git/'"
|
||||
),
|
||||
{ protocol: "http", host: "h.local:3000" }
|
||||
);
|
||||
assert.equal(creds.authFailure("! [rejected] main -> main (fetch first)"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git credential helper, through real git", () => {
|
||||
const repo = fs.mkdtempSync(path.join(ROOT, "repo-"));
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", "https://git.example.test/o/r.git"]);
|
||||
|
||||
it("answers from the store, and only our helper is asked", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.equal(cfg.args[1], "credential.helper=");
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.username, "u1");
|
||||
assert.equal(got.password, "stored");
|
||||
assert.ok(!cfg.args.join(" ").includes("stored"), "secret must not reach argv");
|
||||
});
|
||||
|
||||
it("prefers a use-once credential, which never touches the store", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const once = { protocol: "https", host: "git.example.test", username: "u2", password: "once" };
|
||||
const cfg = await creds.gitConfigFor(repo, once);
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.password, "once");
|
||||
assert.equal(creds.list()[0].username, "u1");
|
||||
assert.ok(!cfg.args.join(" ").includes("once"));
|
||||
});
|
||||
|
||||
it("changes nothing when AgentHub holds nothing for the repo's hosts", async () => {
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.deepEqual(cfg, { args: [], env: {} });
|
||||
});
|
||||
|
||||
it("refuses a use-once credential for a host the repo does not use", async () => {
|
||||
await assert.rejects(
|
||||
creds.gitConfigFor(repo, {
|
||||
protocol: "https",
|
||||
host: "evil.test",
|
||||
username: "u",
|
||||
password: "p",
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Issue #45: a dashboard fetch/pull/push against a remote that needs a
|
||||
* login. A local HTTP "remote" answers 401 to every request and records the
|
||||
* Authorization header it was sent, so the tests see exactly what git sent:
|
||||
* nothing → EGITAUTH naming the host; a typed credential → that credential,
|
||||
* as Basic auth, to that host; saved only when git succeeded AND asked to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = 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-ops-auth-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
// The machine's own helpers must not answer for the fake host.
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
let server;
|
||||
let host;
|
||||
const seen = [];
|
||||
const LANE = path.join(ROOT, "lane");
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
seen.push(req.headers.authorization || null);
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${server.address().port}`;
|
||||
execFileSync("git", ["init", "-q", LANE]);
|
||||
execFileSync("git", ["-C", LANE, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
});
|
||||
after(() => new Promise((r) => server.close(r)));
|
||||
beforeEach(() => {
|
||||
seen.length = 0;
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("git auth on fetch", () => {
|
||||
it("answers EGITAUTH naming the host when git has no credential", async () => {
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), (e) => {
|
||||
assert.equal(e.code, "EGITAUTH");
|
||||
assert.deepEqual(e.auth, { protocol: "http", host, username: null, rejected: false });
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a typed credential as Basic auth, and does not save a failed one", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "http", host, username: "vi", password: "tok", remember: true },
|
||||
}),
|
||||
(e) => e.code === "EGITAUTH" && e.auth.rejected === true
|
||||
);
|
||||
const basic = `Basic ${Buffer.from("vi:tok").toString("base64")}`;
|
||||
assert.ok(seen.includes(basic), `expected ${basic} in ${JSON.stringify(seen)}`);
|
||||
assert.deepEqual(creds.list(), [], "a credential git rejected is never saved");
|
||||
});
|
||||
|
||||
it("uses a stored credential without being asked", async () => {
|
||||
creds.save({ protocol: "http", host, username: "saved", password: "s3" });
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), { code: "EGITAUTH" });
|
||||
assert.ok(seen.includes(`Basic ${Buffer.from("saved:s3").toString("base64")}`));
|
||||
});
|
||||
|
||||
it("refuses a credential for a host that is not this repo's remote", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "https", host: "evil.test", username: "u", password: "p" },
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Issue #45 part B: merge and conflict resolution from the dashboard,
|
||||
* against a REAL repo whose `main` and `feat` edit the same line. Pins the
|
||||
* guards (clean tree, repo-root lane, a ref that exists, one merge at a time,
|
||||
* a path that IS conflicted, markers gone before "resolved", confirm on
|
||||
* abort) and the happy paths (ours, theirs, hand-edited, commit, abort).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = 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 { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
fs.mkdirSync(path.join(REPO, "sub"));
|
||||
write("a.txt", "base\n");
|
||||
write("sub/s.txt", "s\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
g("switch", "-qc", "feat");
|
||||
write("a.txt", "theirs\n");
|
||||
write("b.txt", "only on feat\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "feat");
|
||||
g("switch", "-q", "main");
|
||||
write("a.txt", "ours\n");
|
||||
g("commit", "-qam", "main");
|
||||
});
|
||||
|
||||
const merge = () => runGitAction(REPO, "merge", { branch: "feat" });
|
||||
|
||||
describe("merge", () => {
|
||||
it("reports conflicts instead of failing, and gitFacts shows the merge", async () => {
|
||||
const r = await merge();
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.conflicts, ["a.txt"]);
|
||||
const facts = await gitFacts(REPO);
|
||||
assert.equal(facts.merging, true);
|
||||
assert.equal(facts.conflicts, 1);
|
||||
});
|
||||
|
||||
it("merges cleanly when there is nothing to resolve", async () => {
|
||||
write("a.txt", "theirs\n");
|
||||
g("commit", "-qam", "agree");
|
||||
const r = await merge();
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(read("b.txt"), "only on feat\n");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
});
|
||||
|
||||
it("refuses a dirty tree, an unknown ref, a subdirectory lane and a second merge", async () => {
|
||||
write("a.txt", "wip\n");
|
||||
await assert.rejects(merge(), { code: "EDIRTY" });
|
||||
g("checkout", "--", "a.txt");
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "nope" }), { code: "ENOBRANCH" });
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "--help" }), { code: "EBADBRANCH" });
|
||||
await assert.rejects(runGitAction(path.join(REPO, "sub"), "merge", { branch: "feat" }), {
|
||||
code: "ESUBDIR",
|
||||
});
|
||||
await merge();
|
||||
await assert.rejects(merge(), { code: "EMERGING" });
|
||||
await assert.rejects(runGitAction(REPO, "pull"), { code: "EMERGING" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("takes our side or theirs, and only for a conflicted path", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "b.txt", side: "ours" }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "mine" }), {
|
||||
code: "EBADSIDE",
|
||||
});
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
|
||||
assert.equal(read("a.txt"), "theirs\n");
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
|
||||
it("marks a hand-edited file resolved only once its markers are gone", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }), {
|
||||
code: "EMARKERS",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "stage", { paths: ["a.txt"] }), { code: "EMARKERS" });
|
||||
write("a.txt", "both\n");
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "done" });
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("finish or abort", () => {
|
||||
it("commits the merge only when nothing is left unresolved", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
|
||||
await runGitAction(REPO, "merge-commit");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(g("rev-list", "--parents", "-n1", "HEAD").trim().split(" ").length, 3);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
|
||||
});
|
||||
|
||||
it("aborts only with confirm, back to where it started", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
|
||||
await runGitAction(REPO, "merge-abort", { confirm: true });
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
assert.equal(fs.existsSync(path.join(REPO, "b.txt")), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user