Files
AgentHub/server/lib/git-credentials.js
T
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00

208 lines
6.7 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) {
// S3: a member's git runs as the member (sudo), which can neither read this
// host-owned store nor carry a typed password (sudo logs its command line).
// Their own git login applies (`git credential`, `gh auth` in their terminal).
if (require("./run-as").accountForPath(dir)) {
if (once) {
throw err(
"EMEMBERCRED",
"log in to this git host from your own terminal (git credential store or gh auth login)"
);
}
return { args: [], env: {} };
}
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,
};