f514602697
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.
196 lines
6.2 KiB
JavaScript
196 lines
6.2 KiB
JavaScript
/**
|
|
* @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,
|
|
};
|