feat: file upload/download and pinned sessions, text size, git identity and clone

Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:21:55 +07:00
parent 0cb5e41faf
commit cb19d7e5d2
63 changed files with 3833 additions and 209 deletions
+570
View File
@@ -0,0 +1,570 @@
/**
* @file A user's OWN git identity — the SSH key and the HTTPS logins a clone
* (and every later fetch/pull/push in the cloned repo) authenticates with —
* and the clone itself (`cloneRepo`, behind `POST /api/lanes/clone`).
*
* Whose identity is decided by an ACCOUNT, never by the request:
* - a member (S3, `run-as.js`): everything lives in their own home, under
* `~/.config/agenthub/` — `ssh/id_ed25519` (+ `.pub`) and `git-credentials`
* (git's own `credential-store` format) — written only AS the member
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
* Their git runs as them, so it reads these itself; no secret ever crosses
* sudo's argv or env (sudo logs both).
* - the host (local mode, an admin's folder outside the users root): the
* shared store of `git-credentials.js` for HTTPS, and one key under
* `<CLAUDE_HOME>/agenthub-ssh/` (0700/0600) for SSH.
*
* The key pair is made here (Node's ed25519) and written in OpenSSH's own
* format, because a member's sudoers allows git but not `ssh-keygen`. Only the
* PUBLIC half is ever returned. A clone persists the identity into the new
* repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a
* `credential.helper=store --file=<the member's store>` — so an agent's push
* from a pane uses the same login without touching the user's global config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const gitCredentials = require("./git-credentials");
const runAs = require("./run-as");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
/** A clone may take a while on a big repo; past this it is killed. */
const CLONE_TIMEOUT_MS = 10 * 60_000;
const HELPER = path.join(__dirname, "git-credential-helper.js");
const err = (code, message) => Object.assign(new Error(message), { code });
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
// ── Accounts ───────────────────────────────────────────────────────────────
/** `{kind: "member", acct}` or `{kind: "host"}` for the folder `dir`. */
function accountForDir(dir) {
const acct = runAs.accountForPath(dir);
return acct ? { kind: "member", acct } : { kind: "host" };
}
/**
* The identity a signed-in user manages in Settings: their own Linux account
* under S3, else the host's — which only an admin (or local mode) may change.
*/
function accountForRequest(req) {
const user = req?.user;
if (user) {
const acct = runAs.accountForUser(user.username);
if (acct) return { kind: "member", acct };
}
return { kind: "host", writable: !user || user.role === "admin" };
}
function paths(account) {
if (account.kind === "member") {
const base = path.join(account.acct.home, ".config", "agenthub");
return {
base,
sshDir: path.join(base, "ssh"),
key: path.join(base, "ssh", "id_ed25519"),
store: path.join(base, "git-credentials"),
};
}
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
return { base: sshDir, sshDir, key: path.join(sshDir, "id_ed25519"), store: null };
}
/**
* A file's text, or null when it is missing. `strict`: any OTHER failure
* (unreadable, not the member's own file) throws — a store about to be
* rewritten must never be read as empty, or the rewrite drops every login.
*/
function readText(account, file, { strict = false } = {}) {
try {
return account.kind === "member"
? runAs.readFileSafe(file, "utf8", { max: 256 * 1024 })
: fs.readFileSync(file, "utf8");
} catch (e) {
if (strict && e.code !== "ENOENT") {
throw err("ESTOREUNREADABLE", `cannot read ${path.basename(file)}: ${e.code || e.message}`);
}
return null;
}
}
/** Whether `file` is a regular file (a member's: one they own) — never read. */
function isOwnFile(account, file) {
try {
const st = fs.lstatSync(file);
return st.isFile() && (account.kind !== "member" || st.uid === account.acct.uid);
} catch {
return false;
}
}
/** Write `content` 0600 (`private`) or 0644, making the folder first. */
function writeText(account, file, content, { secret }) {
if (account.kind === "member") {
runAs.mkdirAs(account.acct, path.dirname(file));
runAs.writeAs(account.acct, file, secret ? "private" : "replace", content);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, content, { mode: secret ? 0o600 : 0o644 });
fs.renameSync(tmp, file);
}
function removeFile(account, file) {
if (account.kind === "member") runAs.removeAs(account.acct, file);
else fs.rmSync(file, { force: true });
}
// ── SSH key ────────────────────────────────────────────────────────────────
const u32 = (n) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n >>> 0);
return b;
};
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
/**
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
* to type a passphrase into.
*/
function makeKeyPair(comment) {
const pair = crypto.generateKeyPairSync("ed25519");
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
const type = Buffer.from("ssh-ed25519");
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
const check = crypto.randomBytes(4);
let priv = Buffer.concat([
check,
check,
sshString(type),
sshString(pub),
sshString(Buffer.concat([seed, pub])),
sshString(Buffer.from(comment, "utf8")),
]);
const pad = [];
for (let i = 1; (priv.length + pad.length) % 8 !== 0; i++) pad.push(i);
priv = Buffer.concat([priv, Buffer.from(pad)]);
const body = Buffer.concat([
Buffer.from("openssh-key-v1\0"),
sshString(Buffer.from("none")),
sshString(Buffer.from("none")),
sshString(Buffer.alloc(0)),
u32(1),
sshString(pubBlob),
sshString(priv),
]);
const b64 = body.toString("base64").replace(/.{70}/g, "$&\n");
return {
privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.replace(/\n$/, "")}\n-----END OPENSSH PRIVATE KEY-----\n`,
publicKey: `ssh-ed25519 ${pubBlob.toString("base64")} ${comment}\n`,
};
}
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
function fingerprint(publicKey) {
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
}
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const { key } = paths(account);
const publicKey = readText(account, `${key}.pub`);
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
// The private half is only looked at, never read into this process.
if (!isOwnFile(account, key)) return null;
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
}
/** Make the account's key; an existing one is replaced only with `replace`. */
function generateSshKey(account, { replace = false, comment } = {}) {
if (sshKeyInfo(account) && !replace) {
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
}
const label = String(comment || "agenthub")
.replace(/[^\w.@+-]/g, "")
.slice(0, 64);
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
const { key } = paths(account);
writeText(account, key, privateKey, { secret: true });
writeText(account, `${key}.pub`, publicKey, { secret: false });
return sshKeyInfo(account);
}
function removeSshKey(account) {
const { key } = paths(account);
if (!sshKeyInfo(account)) return false;
removeFile(account, key);
removeFile(account, `${key}.pub`);
return true;
}
// ── HTTPS logins ───────────────────────────────────────────────────────────
function hostKey(protocol, host) {
const p = String(protocol || "").toLowerCase();
const h = String(host || "").toLowerCase();
if (p !== "https" || !HOST_RE.test(h)) throw err("EBADHOST", "an https host[:port] is required");
return { protocol: p, host: h };
}
/** The member's store, parsed: `[{protocol, host, username, password}]`. */
function readStore(account, { strict = false } = {}) {
const text = readText(account, paths(account).store, { strict }) || "";
const rows = [];
for (const line of text.split("\n")) {
try {
const u = new URL(line.trim());
if (u.protocol !== "https:" || !u.password) continue;
rows.push({
protocol: "https",
host: u.host.toLowerCase(),
username: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
});
} catch {
/* not a credential line */
}
}
return rows;
}
function writeStore(account, rows) {
const enc = (s) => encodeURIComponent(s);
const text = rows
.map((r) => `https://${enc(r.username)}:${enc(r.password)}@${r.host}\n`)
.join("");
writeText(account, paths(account).store, text, { secret: true });
}
function listHttps(account) {
if (account.kind === "host") return gitCredentials.list();
return readStore(account).map(({ protocol, host, username }) => ({
protocol,
host,
username,
hasToken: true,
}));
}
function checkCred(username, password) {
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");
}
}
function saveHttps(account, { protocol = "https", host, username, password }) {
if (account.kind === "host") return gitCredentials.save({ protocol, host, username, password });
const k = hostKey(protocol, host);
checkCred(username, password);
const rows = readStore(account, { strict: true }).filter((r) => r.host !== k.host);
rows.push({ ...k, username: username.trim(), password });
writeStore(account, rows);
}
function removeHttps(account, protocol, host) {
if (account.kind === "host") return gitCredentials.remove(protocol, host);
const k = hostKey(protocol, host);
const rows = readStore(account, { strict: true });
const kept = rows.filter((r) => r.host !== k.host);
if (kept.length === rows.length) return false;
writeStore(account, kept);
return true;
}
// ── Commit author ──────────────────────────────────────────────────────────
/** The folder a `git config --global` for this account runs in. */
const homeOf = (account) => (account.kind === "member" ? account.acct.home : os.homedir());
/**
* `{name, email}` of the account's global git config (`user.name` /
* `user.email`, null when unset). As the member under S3: their own
* `~/.gitconfig`, which their panes' commits use too.
*/
async function getAuthor(account) {
const { git } = require("./worktree");
const get = (k) =>
git(homeOf(account), ["config", "--global", "--get", k]).then(
(r) => r.stdout.trim() || null,
() => null
);
return { name: await get("user.name"), email: await get("user.email") };
}
/**
* Set the account's commit author. Neither is a secret, so they may cross
* sudo's argv; each is one line, a name up to 100 and an email up to 254
* characters, the email `local@domain`.
*/
async function setAuthor(account, { name, email }) {
const n = typeof name === "string" ? name.trim() : "";
const e = typeof email === "string" ? email.trim() : "";
// eslint-disable-next-line no-control-regex
const ctl = /[\x00-\x1f\x7f]/;
if (!n || n.length > 100 || ctl.test(n)) throw err("EBADAUTHOR", "a name of 1–100 characters");
if (e.length > 254 || ctl.test(e) || !/^[^\s@]+@[^\s@]+$/.test(e)) {
throw err("EBADAUTHOR", "an email like name@example.com");
}
const { git } = require("./worktree");
await git(homeOf(account), ["config", "--global", "user.name", n]);
await git(homeOf(account), ["config", "--global", "user.email", e]);
return getAuthor(account);
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
* `-c core.sshCommand=…` for a repo with an ssh remote and no sshCommand of
* its own: the account's AgentHub key when there is one (the default keys
* are still tried — no IdentitiesOnly here), and never a prompt — no terminal
* answers one. A repo that sets its own sshCommand keeps it.
*/
async function sshConfigFor(dir) {
const { git } = require("./worktree");
const remotes = await git(dir, ["remote", "-v"]).then(
(r) => r.stdout,
() => ""
);
// Anything that is not http(s) or a local path is ssh to git.
const ssh = remotes
.split("\n")
.some((l) => /\t(ssh:\/\/|[^\s/:]+@[^\s:]+:)/i.test(l) && !/\t(https?|file):\/\//i.test(l));
if (!ssh) return [];
const own = await git(dir, ["config", "--get", "core.sshCommand"]).then(
(r) => r.stdout.trim(),
() => ""
);
if (own) return [];
let account;
try {
account = accountForDir(dir);
} catch {
return [];
}
const key = sshKeyInfo(account) ? paths(account).key : null;
return [
"-c",
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
];
}
/**
* A member's (S3) https login for a fetch/pull/push: their own store, given
* to git as the only helper while it holds one of the repo's hosts. `once`
* (typed in the git bar, for one of `hosts` only) goes INTO that store
* through `write-as`'s stdin — the only way a secret reaches git running as
* them — and `finish(ok)` takes it out again unless it worked and
* `remember` was set. `stored(host)`: whether the store holds that host.
*/
function memberHttpsConfig(account, hosts, once) {
const before = readStore(account, { strict: true });
let finish = () => {};
if (once) {
const k = hostKey(once.protocol || "https", once.host);
if (!hosts.some((h) => h.host === k.host)) {
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
}
checkCred(once.username, once.password);
saveHttps(account, { host: k.host, username: once.username, password: once.password });
finish = (ok) => {
if (ok && once.remember) return;
try {
writeStore(account, before);
} catch {
/* best effort: the store is the member's own */
}
};
}
const row = (host) => {
const r = before.find((x) => x.host === String(host).toLowerCase());
return r ? { username: r.username } : null;
};
const args =
once || hosts.some((h) => h.protocol === "https" && row(h.host))
? [
"-c",
"credential.helper=",
"-c",
`credential.helper=store --file=${shq(paths(account).store)}`,
]
: [];
return { args, finish, stored: (_protocol, host) => row(host) };
}
// ── Clone ──────────────────────────────────────────────────────────────────
/** `{scheme: "ssh"|"https", host}` of a URL `validateRemoteUrl` accepted. */
function remoteOf(url) {
if (url.startsWith("https://")) return { scheme: "https", host: new URL(url).host.toLowerCase() };
if (url.startsWith("ssh://")) return { scheme: "ssh", host: new URL(url).host.toLowerCase() };
return { scheme: "ssh", host: url.slice(url.indexOf("@") + 1, url.indexOf(":")).toLowerCase() };
}
/** The folder name a clone of `url` gets by default — git's own rule, roughly. */
function defaultName(url) {
const tail =
url
.replace(/[/:]+$/, "")
.split(/[/:]/)
.pop() || "";
return tail.replace(/\.git$/i, "");
}
/** git's wording for "I needed an https login and did not get a good one". */
function httpsFailure(stderr) {
return /could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
String(stderr || "")
);
}
/** The forge's "no such repository" — over ssh it ALSO says "Could not read
* from remote repository", which is not a refused key. */
function notFound(stderr) {
return /Repository not found|does not appear to be a git repository|repository '.*' not found|project you were looking for could not be found/i.test(
String(stderr || "")
);
}
/** git's wording for "the SSH login was refused or the host is unknown". */
function sshFailure(stderr) {
return /Permission denied \(publickey|Host key verification failed|Could not read from remote repository/i.test(
String(stderr || "")
);
}
/**
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
* that account's own identity. `once` is a login typed for this clone
* (`{username, password, remember}`) — https only, for this URL's host only.
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
* was refused, else the git error.
*/
async function cloneRepo({ url, parent, name, once = null }) {
const { git } = require("./worktree");
const account = accountForDir(parent);
const remote = remoteOf(url);
const pre = [];
const persist = [];
const env = {};
let restore = null;
if (remote.scheme === "ssh") {
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
const key = sshKeyInfo(account) ? paths(account).key : null;
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
if (key) persist.push("-c", `core.sshCommand=${base}`);
} else if (account.kind === "member") {
const k = hostKey("https", remote.host);
const before = readStore(account, { strict: true });
if (once) {
checkCred(once.username, once.password);
saveHttps(account, { host: k.host, username: once.username, password: once.password });
restore = () => writeStore(account, before);
}
if (once || before.some((r) => r.host === k.host)) {
const helper = `store --file=${shq(paths(account).store)}`;
pre.push("-c", "credential.helper=", "-c", `credential.helper=${helper}`);
if (!once || once.remember) persist.push("-c", `credential.helper=${helper}`);
}
} else {
const k = hostKey("https", remote.host);
if (once) {
checkCred(once.username, once.password);
env.AGENTHUB_GIT_ONCE = JSON.stringify({
...k,
username: once.username,
password: once.password,
});
}
if (once || gitCredentials.find(k.protocol, k.host)) {
env.AGENTHUB_GIT_CRED_FILE = gitCredentials.credentialsFile();
pre.push(
"-c",
"credential.helper=",
"-c",
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`
);
}
}
let ok = false;
try {
await git(parent, [...pre, "clone", ...persist, "--", url, name], {
timeout: CLONE_TIMEOUT_MS,
env,
});
ok = true;
} catch (e) {
const stderr = e.git?.stderr;
if (remote.scheme === "https" && httpsFailure(stderr)) {
throw Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
protocol: "https",
host: remote.host,
});
}
if (notFound(stderr)) {
throw err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
}
if (remote.scheme === "ssh" && sshFailure(stderr)) {
throw Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
}
throw err(
"ECLONE",
String(stderr || e.message)
.trim()
.slice(-800)
);
} finally {
// A login typed without "remember" — or one that did not work — leaves
// the member's store as it was.
if (restore && (!ok || !once.remember)) {
try {
restore();
} catch {
/* best effort: the store is the member's own */
}
}
}
if (ok && once?.remember && account.kind === "host") {
gitCredentials.save({ protocol: "https", host: remote.host, ...once });
}
return { dir: path.join(parent, name), account: account.kind };
}
module.exports = {
accountForDir,
accountForRequest,
makeKeyPair,
fingerprint,
sshKeyInfo,
generateSshKey,
removeSshKey,
listHttps,
saveHttps,
removeHttps,
remoteOf,
defaultName,
cloneRepo,
getAuthor,
setAuthor,
sshConfigFor,
memberHttpsConfig,
CLONE_TIMEOUT_MS,
};