Files
AgentHub/server/lib/git-credentials.js
T
nntrivi2001 5fe5d51b1b feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
2026-10-06 22:48:09 +07:00

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 Proxy 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,
};