84e2a9f0ac
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>
208 lines
6.7 KiB
JavaScript
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,
|
|
};
|