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:
2026-09-25 10:44:40 +07:00
parent 605ca9d65e
commit f514602697
30 changed files with 2005 additions and 59 deletions
+4 -1
View File
@@ -532,7 +532,10 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as
@@ -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);
});
});
+123
View File
@@ -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" }
);
});
});
+85
View File
@@ -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);
});
});
+136
View File
@@ -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);
});
});
+2
View File
@@ -74,6 +74,7 @@ const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config");
const aiRoutersRouter = require("./routes/ai-routers");
const gitCredentialsRouter = require("./routes/git-credentials");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
const webhooksRouter = require("./routes/webhooks");
@@ -117,6 +118,7 @@ function createApp() {
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/codex-config", codexConfigRouter);
app.use("/api/ai-routers", aiRoutersRouter);
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/locks", locksRouter);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* @file The git credential helper AgentHub installs on its own network git
* commands (`-c credential.helper=!node <this>`, see git-credentials.js).
* Speaks git's credential protocol: `get` reads `key=value` lines on stdin
* and answers `username=` / `password=` for a matching protocol + host —
* first from `AGENTHUB_GIT_ONCE` (a credential typed for this one command),
* then from the store file named by `AGENTHUB_GIT_CRED_FILE`. `store` and
* `erase` do nothing: AgentHub saves only what the user asked it to keep, and
* only after git has proved it works. Prints nothing else, ever.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
if (process.argv[2] !== "get") process.exit(0);
let input = "";
process.stdin.on("data", (c) => (input += c));
process.stdin.on("end", () => {
const want = {};
for (const line of input.split("\n")) {
const i = line.indexOf("=");
if (i > 0) want[line.slice(0, i)] = line.slice(i + 1);
}
const protocol = String(want.protocol || "").toLowerCase();
const host = String(want.host || "").toLowerCase();
const matches = (r) => r && r.protocol === protocol && r.host === host && r.password;
let hit = null;
try {
const once = JSON.parse(process.env.AGENTHUB_GIT_ONCE || "null");
if (matches(once)) hit = once;
} catch {
/* malformed: fall through to the store */
}
if (!hit && process.env.AGENTHUB_GIT_CRED_FILE) {
try {
const rows = JSON.parse(fs.readFileSync(process.env.AGENTHUB_GIT_CRED_FILE, "utf8"));
hit = (Array.isArray(rows) ? rows : []).find(matches) || null;
} catch {
/* no store yet */
}
}
// A helper that answers nothing lets git fail with its own auth error,
// which the dashboard turns into the login form.
if (hit) process.stdout.write(`username=${hit.username}\npassword=${hit.password}\n`);
});
+195
View File
@@ -0,0 +1,195 @@
/**
* @file AgentHub's own git credentials — what a dashboard fetch/pull/push
* authenticates with when the machine's git has nothing (issue #45). The
* dashboard runs git with no terminal, so without this a remote needing a
* login fails with "could not read Username … terminal prompts disabled".
*
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
* file 0600, written through a temp file), one record per protocol + host,
* shared by everyone using this AgentHub. Like the AI Router store, **no route
* ever returns a password** — `list` reports `hasToken`.
*
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
* helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only
* ours, because a stale token in the machine's helper would win over the one
* just typed, and git's post-success `store` would copy a use-once token into
* the OS user's own helper. The secret itself never reaches argv.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
const PROTOCOLS = new Set(["https", "http"]);
const HELPER = path.join(__dirname, "git-credential-helper.js");
function err(code, message) {
return Object.assign(new Error(message), { code });
}
function credentialsFile() {
return path.join(getClaudeHome(), "agenthub-git-credentials", "credentials.json");
}
/** `{protocol, host}` normalised, or EBADHOST — a host is also a lookup key. */
function key(protocol, host) {
const p = String(protocol || "").toLowerCase();
const h = String(host || "").toLowerCase();
if (!PROTOCOLS.has(p) || !HOST_RE.test(h)) {
throw err("EBADHOST", "protocol must be http(s) and host a plain host[:port]");
}
return { protocol: p, host: h };
}
function readAll() {
try {
const rows = JSON.parse(fs.readFileSync(credentialsFile(), "utf8"));
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
function writeAll(rows) {
const file = credentialsFile();
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, file);
}
function list() {
return readAll().map(({ protocol, host, username, savedAt }) => ({
protocol,
host,
username,
savedAt,
hasToken: true,
}));
}
function find(protocol, host) {
const k = key(protocol, host);
return readAll().find((r) => r.protocol === k.protocol && r.host === k.host) || null;
}
function save({ protocol, host, username, password }) {
const k = key(protocol, host);
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
throw err("EBADCRED", "username is required");
}
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
throw err("EBADCRED", "password or access token is required");
}
const rows = readAll().filter((r) => !(r.protocol === k.protocol && r.host === k.host));
rows.push({ ...k, username: username.trim(), password, savedAt: new Date().toISOString() });
writeAll(rows);
}
function remove(protocol, host) {
const k = key(protocol, host);
const rows = readAll();
const kept = rows.filter((r) => !(r.protocol === k.protocol && r.host === k.host));
if (kept.length === rows.length) return false;
writeAll(kept);
return true;
}
/**
* git's wording for "I needed a login and did not get one (or it was wrong)",
* and the URL it names. null for every other failure.
*/
function authFailure(stderr) {
const text = String(stderr || "");
if (
!/could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
text
)
) {
return null;
}
const m = text.match(/'(https?):\/\/(?:[^@'/]*@)?([^/'\s]+)/i);
if (!m) return null;
try {
return key(m[1], m[2]);
} catch {
return null;
}
}
/** The http(s) hosts a repo's remotes point at. */
async function remoteHosts(dir) {
// Required lazily: worktree.js is heavy and only this path needs it.
const { git } = require("./worktree");
const out = await git(dir, ["remote", "-v"]).then(
(r) => r.stdout,
() => ""
);
const hosts = new Map();
// A username in the URL (https://vi@host/…) pre-fills the login form; a
// password in it (user:pass@) is never read back out.
for (const m of out.matchAll(/\t(https?):\/\/(?:([^@\s/:]*)(?::[^@\s/]*)?@)?([^/\s]+)/gi)) {
try {
const k = key(m[1], m[3]);
hosts.set(`${k.protocol}://${k.host}`, {
...k,
username: m[2] ? decodeURIComponent(m[2]) : null,
});
} catch {
/* not a host we can hold a credential for */
}
}
return [...hosts.values()];
}
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
/**
* The `-c` args and env a network git command runs with. `once` is a
* credential from the request, accepted only for one of the repo's own
* remote hosts — a key typed for one server is never sent to another.
*/
async function gitConfigFor(dir, once) {
const hosts = await remoteHosts(dir);
const env = {};
if (once) {
const k = key(once.protocol, once.host);
if (!hosts.some((h) => h.protocol === k.protocol && h.host === k.host)) {
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
}
env.AGENTHUB_GIT_ONCE = JSON.stringify({
...k,
username: String(once.username || ""),
password: String(once.password || ""),
});
} else if (!hosts.some((h) => find(h.protocol, h.host))) {
return { args: [], env: {} };
}
// ponytail: while AgentHub answers for one host, a remote on ANOTHER host
// gets no machine helper either (see the file header); chain the machine's
// helpers for unknown hosts if mixed-host `fetch --all` ever matters.
env.AGENTHUB_GIT_CRED_FILE = credentialsFile();
return {
args: [
"-c",
"credential.helper=",
"-c",
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`,
],
env,
};
}
module.exports = {
credentialsFile,
list,
find,
save,
remove,
authFailure,
remoteHosts,
gitConfigFor,
};
+1
View File
@@ -907,6 +907,7 @@ async function listTreeAll(dir) {
module.exports = {
collectChanges,
CONFLICT_CODES,
contentSha,
isSecretOnDisk,
lanePath,
+181 -20
View File
@@ -1,11 +1,13 @@
/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, and create or
* delete a branch. Everything here is deliberately the boring half of git —
* there is no merge, no rebase, no reset, no stash and no conflict
* resolution, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (`reset` stays where it was:
* behind the three-check destroy guard in worktree.js).
* stage/unstage a file, commit, discard a file, switch branch, create or
* delete a branch, and merge a branch with its conflict resolution (issue
* #45: merge, resolve ours/theirs/hand-edited, merge-commit, merge-abort).
* There is no rebase, no reset and no stash, because a dashboard button is
* the wrong place to start an operation whose recovery needs a terminal
* (`reset` stays where it was: behind the three-check destroy guard in
* worktree.js). A merge is the exception because its recovery is ONE button
* here too — `merge-abort` puts the branch back where it was.
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
@@ -18,11 +20,13 @@
* `git status` is reporting as changed right now (`resolvePaths`), so no
* `..`, no absolute path and no untouched file is reachable.
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
* invent a merge commit or a conflict underneath a running agent.
* invent a merge commit or a conflict underneath a running agent. A merge
* happens only when asked for by name (`merge`), on a clean tree, from a
* lane that IS the repo root (a merge is repo-wide), one at a time.
*
* `discard` is the ONE irreversible action here — it throws away work git has
* no copy of — so it additionally demands an explicit `confirm: true` in the
* body. Do not add a second destructive action without the same gate.
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
* of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* Network actions carry a timeout: a lane pointed at an unreachable remote
* should fail in half a minute, not hold an HTTP request open.
@@ -39,7 +43,8 @@ const {
branchCheckedOutAt,
statusCounts,
} = require("./worktree");
const { hunkPatch, laneStatus } = require("./git-diff");
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -61,8 +66,18 @@ const ACTIONS = new Set([
"discard-hunk",
"branch-create",
"branch-delete",
"merge",
"resolve",
"merge-commit",
"merge-abort",
]);
/** Actions that would tangle with a merge in progress. */
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** Branches this module refuses to delete, whatever the request says. */
const PROTECTED = new Set(["main", "master", "development", "develop"]);
@@ -159,6 +174,49 @@ async function resolvePaths(dir, body) {
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
}
/**
* A fetch/pull/push, authenticated with AgentHub's own git credentials
* (git-credentials.js): a stored one for the remote's host, or
* `body.credentials` typed for this one call. A login failure becomes
* EGITAUTH carrying the host, which the git bar turns into a login form; a
* typed credential is saved only after git accepted it and only if asked.
*/
async function networkGit(dir, args, body = {}) {
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
const cfg = await gitCredentials.gitConfigFor(dir, typed);
try {
const out = await git(dir, [...cfg.args, ...args], {
timeout: NETWORK_TIMEOUT_MS,
env: cfg.env,
});
if (typed?.remember) gitCredentials.save(typed);
return out;
} catch (e) {
const auth = gitCredentials.authFailure(e.git?.stderr);
if (!auth) throw e;
const remote = (await gitCredentials.remoteHosts(dir)).find(
(h) => h.protocol === auth.protocol && h.host === auth.host
);
const stored = gitCredentials.find(auth.protocol, auth.host);
const wrong = Boolean(typed) || Boolean(stored);
throw Object.assign(
new Error(
wrong
? `${auth.host} rejected the username or token`
: `${auth.host} needs a username and password or access token`
),
{
code: "EGITAUTH",
auth: {
...auth,
username: typed?.username || stored?.username || remote?.username || null,
rejected: wrong,
},
}
);
}
}
/**
* Run one action against a lane's working directory.
*
@@ -172,18 +230,19 @@ async function resolvePaths(dir, body) {
*/
async function runGitAction(dir, action, body = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
}
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
if (action === "fetch") {
return done(
action,
await git(dir, ["fetch", "--all", "--prune"], { timeout: NETWORK_TIMEOUT_MS })
);
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
}
if (action === "pull") {
// --ff-only on purpose: a divergent branch is reported, never merged. The
// user (or the agent in the pane) decides how to reconcile it.
return done(action, await git(dir, ["pull", "--ff-only"], { timeout: NETWORK_TIMEOUT_MS }));
return done(action, await networkGit(dir, ["pull", "--ff-only"], body));
}
if (action === "push") {
@@ -191,14 +250,19 @@ async function runGitAction(dir, action, body = {}) {
// is actually on, and -u makes the first push set the upstream the
// ahead/behind counts then read.
const remote = await pushRemote(dir);
return done(
action,
await git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
);
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
}
if (action === "stage") {
const paths = await resolvePaths(dir, body);
// Staging a conflicted file marks it resolved: not while git's markers
// are still in it (the same check `resolve` makes).
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
const file = path.join(dir, p.path);
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
throw bad("EMARKERS", `${p.path} still has conflict markers`);
}
}
// `add` covers every case the panel can show: a modification, a new file
// and a deletion all stage with the same verb.
return done(action, await git(dir, ["add", "--", ...paths.map((p) => p.path)]));
@@ -336,6 +400,103 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["switch", branch]), branch);
}
/** Whether a merge is in progress (MERGE_HEAD exists). */
async function isMerging(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
return true;
} catch {
return false;
}
}
/** Paths git still holds as unmerged, repo-wide. */
async function unmergedPaths(dir) {
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
.split("\0")
.filter(Boolean);
}
/** merge / resolve / merge-commit / merge-abort. */
async function mergeAction(dir, action, body) {
if (action === "merge") {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
// A merge moves the whole repo, so only a lane that IS the repo may start
// one — a subdirectory lane could not see, let alone resolve, a conflict
// outside its own folder.
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
"a merge changes the whole repository; run it from a lane at the repo root"
);
}
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
// The ref is looked up, never passed through: git sees the full refname
// of a local or remote-tracking branch that exists, nothing else.
const refs = (
await git(dir, [
"for-each-ref",
"--format=%(refname:short)%00%(refname)",
"refs/heads",
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
const ref = refs.find(([short]) => short === branch)?.[1];
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
try {
const out = await git(dir, ["merge", "--no-edit", ref]);
return { ...done(action, out, branch), conflicts: [] };
} catch (e) {
// A conflict is a merge that STARTED: report the files, not a failure.
if (!(await isMerging(dir))) throw e;
return {
...done(action, e.git || { stdout: "", stderr: "" }, branch),
conflicts: await unmergedPaths(dir),
};
}
}
if (action === "resolve") {
const [target] = await resolvePaths(dir, { path: body.path });
if (!CONFLICT_CODES.has(target.code))
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
const side = body.side;
if (!["ours", "theirs", "done"].includes(side))
throw bad("EBADSIDE", "side must be ours, theirs or done");
const p = target.path;
const gone =
(side === "ours" && target.code[0] === "D") || (side === "theirs" && target.code[1] === "D");
if (side !== "done" && !gone) {
await git(dir, ["checkout", `--${side}`, "--", p]);
return done(action, await git(dir, ["add", "--", p]));
}
if (side === "done" && fs.existsSync(path.join(dir, p))) {
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
throw bad("EMARKERS", `${p} still has conflict markers`);
}
return done(action, await git(dir, ["add", "--", p]));
}
// The chosen side deleted it (or the hand edit did): resolved as gone.
return done(action, await git(dir, ["rm", "-q", "--", p]));
}
if (action === "merge-abort" && body.confirm !== true) {
throw bad("ECONFIRM", "merge-abort requires confirm: true");
}
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
// merge-commit
const left = await unmergedPaths(dir);
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
return done(action, await git(dir, ["commit", "--no-edit"]));
}
/** Create or delete a branch WITHOUT touching the working tree. */
async function branchAction(dir, action, body) {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
+30 -2
View File
@@ -30,7 +30,11 @@ const STATUS_FILE_CAP = 20;
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false } = {}) {
async function git(
cwd,
args,
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -60,6 +64,8 @@ async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, litera
delete env.GIT_ICASE_PATHSPECS;
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
else delete env.GIT_LITERAL_PATHSPECS;
// Caller-supplied last: the git credential helper's inputs (git-credentials.js).
Object.assign(env, extraEnv);
try {
const result = await execFileAsync("git", args, {
@@ -696,7 +702,29 @@ async function gitFacts(dir) {
const { dirty, untracked, files } = await laneFiles(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
return {
branch,
head,
subject,
dirty,
untracked,
files,
...(await trackingFacts(dir)),
...(await mergeFacts(dir)),
};
}
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
async function mergeFacts(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
} catch {
return { merging: false, conflicts: 0 };
}
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
stdout: "",
}));
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
}
/**
+47
View File
@@ -0,0 +1,47 @@
/**
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
* list, save/replace, delete. Like the AI Router routes, nothing here ever
* returns a password — `GET` reports `hasToken` — and every write is behind
* the same-origin guard. A lane's fetch/pull/push also saves one, through
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const creds = require("../lib/git-credentials");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
function fail(res, e) {
const status = e.code === "EBADHOST" || e.code === "EBADCRED" ? 400 : 500;
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
router.get("/", (_req, res) => {
res.json({ items: creds.list() });
});
router.post("/", sameOriginGuard, (req, res) => {
try {
const { protocol, host, username, password } = req.body || {};
creds.save({ protocol, host, username, password });
res.json({ items: creds.list() });
} catch (e) {
fail(res, e);
}
});
router.delete("/:protocol/:host", sameOriginGuard, (req, res) => {
try {
if (!creds.remove(req.params.protocol, req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such credential" } });
}
res.json({ items: creds.list() });
} catch (e) {
fail(res, e);
}
});
module.exports = router;
+9 -1
View File
@@ -973,6 +973,11 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"ESTAGEDOUTSIDE",
"ENOREMOTE",
"EUNTRACKED",
"EBADHOST",
"EBADCRED",
"ESUBDIR",
"EBADSIDE",
"EMARKERS",
];
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
@@ -982,7 +987,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
// EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
: exited || err.code === "EHUNKGONE"
: exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code)
? 409
: 500;
// git's own stderr is the useful half of a failed push or pull (a
@@ -994,6 +999,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
error: {
code: gitErrorCode(err),
message: detail ? `${err.message}\n${detail}` : err.message,
// EGITAUTH: which host wants a login, for the git bar's form. Never
// a secret — just protocol, host, a username hint and `rejected`.
...(err.auth ? { auth: err.auth } : {}),
},
});
}