Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09efb089cf | |||
| c83ddf62a0 | |||
| d8ab850295 | |||
| bac6c04a0b | |||
| 38c7ac6635 | |||
| 276cead826 |
@@ -7,7 +7,9 @@
|
|||||||
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||||
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||||
* it, and whether git typed in the account's own terminal uses it too (a
|
* it, and whether git typed in the account's own terminal uses it too (a
|
||||||
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82) — and, for
|
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
|
||||||
|
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
|
||||||
|
* that same file, so a plain `ssh://git-codecommit…` URL works) — and, for
|
||||||
* a user with their own Linux account (S3), their own HTTPS
|
* a user with their own Linux account (S3), their own HTTPS
|
||||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||||
* table above it, so this lists them only for a member. The private key and
|
* table above it, so this lists them only for a member. The private key and
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
* seen by a non-admin is read-only.
|
* seen by a non-admin is read-only.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
@@ -30,6 +32,140 @@ const INPUT =
|
|||||||
const BTN =
|
const BTN =
|
||||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||||
|
|
||||||
|
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
|
||||||
|
// The regions AWS CodeCommit runs in.
|
||||||
|
const AWS_REGIONS = [
|
||||||
|
"ap-southeast-1",
|
||||||
|
"ap-southeast-2",
|
||||||
|
"ap-southeast-3",
|
||||||
|
"ap-northeast-1",
|
||||||
|
"ap-northeast-2",
|
||||||
|
"ap-northeast-3",
|
||||||
|
"ap-south-1",
|
||||||
|
"ap-east-1",
|
||||||
|
"us-east-1",
|
||||||
|
"us-east-2",
|
||||||
|
"us-west-1",
|
||||||
|
"us-west-2",
|
||||||
|
"ca-central-1",
|
||||||
|
"eu-west-1",
|
||||||
|
"eu-west-2",
|
||||||
|
"eu-west-3",
|
||||||
|
"eu-central-1",
|
||||||
|
"eu-north-1",
|
||||||
|
"eu-south-1",
|
||||||
|
"sa-east-1",
|
||||||
|
"me-south-1",
|
||||||
|
"af-south-1",
|
||||||
|
"il-central-1",
|
||||||
|
];
|
||||||
|
|
||||||
|
function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 text-[11px] text-fg-secondary">
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind of git host and the key it takes: no key → a Generate button;
|
||||||
|
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||||
|
*/
|
||||||
|
function KeyCard({
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
type,
|
||||||
|
k,
|
||||||
|
id,
|
||||||
|
busy,
|
||||||
|
onGenerate,
|
||||||
|
onRemove,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
sub: string;
|
||||||
|
type: "ed25519" | "rsa";
|
||||||
|
k: Key | undefined;
|
||||||
|
id: GitIdentity;
|
||||||
|
busy: boolean;
|
||||||
|
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||||
|
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
return (
|
||||||
|
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||||
|
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||||
|
</div>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void onGenerate(type)}
|
||||||
|
>
|
||||||
|
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
|
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{k && id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void onRemove(type)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{k ? (
|
||||||
|
<>
|
||||||
|
<Step n={1}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
|
||||||
|
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.copy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||||
|
value={k.publicKey}
|
||||||
|
rows={type === "rsa" ? 3 : 2}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||||
|
/>
|
||||||
|
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
|
||||||
|
</Step>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GitIdentitySettings() {
|
export function GitIdentitySettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [id, setId] = useState<GitIdentity | null>(null);
|
const [id, setId] = useState<GitIdentity | null>(null);
|
||||||
@@ -71,21 +207,21 @@ export function GitIdentitySettings() {
|
|||||||
// An account may hold one of each; an older server answers `ssh` only.
|
// An account may hold one of each; an older server answers `ssh` only.
|
||||||
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
|
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
|
||||||
const typeTaken = keys.some((k) => (k.type ?? "ed25519") === keyType);
|
const generate = async (type: "ed25519" | "rsa") => {
|
||||||
const generate = async () => {
|
const taken = !!keyOf(type);
|
||||||
if (
|
if (
|
||||||
typeTaken &&
|
taken &&
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
title: t("gitIdentity.replaceConfirm"),
|
title: t("gitIdentity.replaceConfirm"),
|
||||||
confirmLabel: t("gitIdentity.replace"),
|
confirmLabel: t("gitIdentity.replace"),
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
await run(() => api.gitIdentity.generateKey(typeTaken, keyType), t("gitIdentity.keyMade"));
|
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeKey = async (type?: "ed25519" | "rsa") => {
|
const removeKey = async (type: "ed25519" | "rsa") => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
title: t("gitIdentity.removeKeyConfirm"),
|
title: t("gitIdentity.removeKeyConfirm"),
|
||||||
@@ -113,6 +249,44 @@ export function GitIdentitySettings() {
|
|||||||
if (ok) setForm(null);
|
if (ok) setForm(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
|
||||||
|
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
|
||||||
|
// one host per region) gets its own form under the RSA key; anything else
|
||||||
|
// is the "other hosts" form.
|
||||||
|
const sshUsers = id?.sshUsers ?? [];
|
||||||
|
const awsRows = sshUsers.flatMap((r) => {
|
||||||
|
const m = CODECOMMIT_RE.exec(r.host);
|
||||||
|
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||||
|
});
|
||||||
|
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||||
|
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1" });
|
||||||
|
const saveAws = async () => {
|
||||||
|
let user = aws.keyId.trim();
|
||||||
|
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||||
|
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||||
|
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||||
|
if (m?.[1] && m[2]) {
|
||||||
|
user = m[1];
|
||||||
|
host = m[2].toLowerCase();
|
||||||
|
}
|
||||||
|
const ok = await run(
|
||||||
|
() => api.gitIdentity.setSshUser({ host, user }),
|
||||||
|
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
|
||||||
|
);
|
||||||
|
if (ok) setAws({ ...aws, keyId: "" });
|
||||||
|
};
|
||||||
|
const [other, setOther] = useState({ host: "", user: "" });
|
||||||
|
const saveOther = async () => {
|
||||||
|
let host = other.host.trim();
|
||||||
|
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
||||||
|
if (m?.[1]) host = m[1];
|
||||||
|
const ok = await run(
|
||||||
|
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
|
||||||
|
t("gitIdentity.otherSaved", { host })
|
||||||
|
);
|
||||||
|
if (ok) setOther({ host: "", user: "" });
|
||||||
|
};
|
||||||
|
|
||||||
const removeLogin = async (host: string) => {
|
const removeLogin = async (host: string) => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
@@ -206,66 +380,111 @@ export function GitIdentitySettings() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||||
{t("gitIdentity.sshTitle")}
|
{t("gitIdentity.sshTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
{id.writable && (
|
|
||||||
<select
|
|
||||||
aria-label={t("gitIdentity.keyType")}
|
|
||||||
value={keyType}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
|
|
||||||
className="select !py-1 !text-xs"
|
|
||||||
>
|
|
||||||
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
|
|
||||||
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{id.writable && (
|
|
||||||
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
|
||||||
{typeTaken ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
||||||
{typeTaken ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{keys.length ? (
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
|
||||||
|
|
||||||
|
<KeyCard
|
||||||
|
title={t("gitIdentity.gitHubTitle")}
|
||||||
|
sub={t("gitIdentity.gitHubSub")}
|
||||||
|
type="ed25519"
|
||||||
|
k={keyOf("ed25519")}
|
||||||
|
id={id}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={generate}
|
||||||
|
onRemove={removeKey}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.gitHubStepClone")}
|
||||||
|
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||||
|
</Step>
|
||||||
|
</KeyCard>
|
||||||
|
|
||||||
|
<KeyCard
|
||||||
|
title={t("gitIdentity.awsTitle")}
|
||||||
|
sub={t("gitIdentity.awsSub")}
|
||||||
|
type="rsa"
|
||||||
|
k={keyOf("rsa")}
|
||||||
|
id={id}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={generate}
|
||||||
|
onRemove={removeKey}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.awsStepKeyId")}
|
||||||
|
{id.sshUsers === undefined ? null : (
|
||||||
<>
|
<>
|
||||||
{keys.map((k) => (
|
{awsRows.map((r) => (
|
||||||
<div key={k.type ?? "ed25519"} className="space-y-1.5">
|
<div key={r.host} className="mt-1 flex items-center gap-2">
|
||||||
<textarea
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
readOnly
|
{r.region} · {r.user}
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${k.type === "rsa" ? "RSA" : "ED25519"}`}
|
|
||||||
value={k.publicKey}
|
|
||||||
rows={k.type === "rsa" ? 4 : 2}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
|
||||||
{k.type === "rsa" ? "RSA 4096 · " : "ED25519 · "}
|
|
||||||
{k.fingerprint}
|
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
{t("gitIdentity.copy")}
|
|
||||||
</button>
|
|
||||||
{id.writable && (
|
{id.writable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${BTN} hover:text-status-danger`}
|
className={`${BTN} hover:text-status-danger`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void removeKey(k.type)}
|
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
||||||
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{t("gitIdentity.remove")}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
{id.writable && (
|
||||||
|
<form
|
||||||
|
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveAws();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.awsKeyId")}
|
||||||
|
placeholder="APKA…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={aws.keyId}
|
||||||
|
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.awsRegion")}
|
||||||
|
value={aws.region}
|
||||||
|
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
{AWS_REGIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Step>
|
||||||
|
{awsRows.length > 0 && (
|
||||||
|
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||||
|
{t("gitIdentity.awsReady")}
|
||||||
|
{awsRows.map((r) => (
|
||||||
|
<Code key={r.host}>git clone ssh://{r.host}/v1/repos/<repo></Code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</KeyCard>
|
||||||
|
|
||||||
{terminal && (
|
{terminal && (
|
||||||
<div className="flex items-start gap-2 rounded border border-border p-2">
|
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -284,12 +503,69 @@ export function GitIdentitySettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
|
||||||
</>
|
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
|
||||||
) : (
|
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
|
||||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
<summary className="cursor-pointer text-[11px] text-fg-primary">
|
||||||
|
{t("gitIdentity.otherTitle")}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||||
|
{otherRows.map((r) => (
|
||||||
|
<div key={r.host} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
|
{r.user}@{r.host}
|
||||||
|
</span>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
||||||
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{id.writable && (
|
||||||
|
<form
|
||||||
|
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveOther();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherHost")}
|
||||||
|
placeholder="git.example.com"
|
||||||
|
value={other.host}
|
||||||
|
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherUser")}
|
||||||
|
placeholder={t("gitIdentity.otherUser")}
|
||||||
|
autoComplete="off"
|
||||||
|
value={other.user}
|
||||||
|
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||||
|
>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{member && (
|
{member && (
|
||||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
* @file Tests for Settings → Git → Your git identity: a user with no key
|
* @file Tests for Settings → Git → Your git identity: a user with no key
|
||||||
* makes one, a key shows only its public half and copies it, replacing asks
|
* makes one, a key shows only its public half and copies it, replacing asks
|
||||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||||
* view leaves HTTPS to the shared Git credentials table.
|
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||||
|
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||||
|
* other host under "Other SSH hosts" — so the sections never mix.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { GitIdentitySettings } from "../GitIdentitySettings";
|
import { GitIdentitySettings } from "../GitIdentitySettings";
|
||||||
import { api, type GitIdentity } from "../../../lib/api";
|
import { api, type GitIdentity } from "../../../lib/api";
|
||||||
@@ -20,6 +22,8 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
generateKey: vi.fn(),
|
generateKey: vi.fn(),
|
||||||
removeKey: vi.fn(),
|
removeKey: vi.fn(),
|
||||||
setTerminalSsh: vi.fn(),
|
setTerminalSsh: vi.fn(),
|
||||||
|
setSshUser: vi.fn(),
|
||||||
|
removeSshUser: vi.fn(),
|
||||||
saveHttps: vi.fn(),
|
saveHttps: vi.fn(),
|
||||||
saveAuthor: vi.fn(),
|
saveAuthor: vi.fn(),
|
||||||
removeHttps: vi.fn(),
|
removeHttps: vi.fn(),
|
||||||
@@ -49,7 +53,8 @@ describe("GitIdentitySettings", () => {
|
|||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
|
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||||
|
await userEvent.click(within(gh).getByRole("button", { name: "Generate key" }));
|
||||||
expect(confirmDialog).not.toHaveBeenCalled();
|
expect(confirmDialog).not.toHaveBeenCalled();
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
||||||
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
|
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
|
||||||
@@ -86,43 +91,84 @@ describe("GitIdentitySettings", () => {
|
|||||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
|
it("makes the RSA key from the AWS CodeCommit section", async () => {
|
||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||||
);
|
);
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
|
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
|
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||||
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
|
expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds an RSA key beside the ED25519 one and deletes each on its own", async () => {
|
it("keeps each key in its own section and deletes each on its own", async () => {
|
||||||
const RSA = {
|
const RSA = {
|
||||||
type: "rsa" as const,
|
type: "rsa" as const,
|
||||||
publicKey: "ssh-rsa AAAAB3 an@agenthub",
|
publicKey: "ssh-rsa AAAAB3 an@agenthub",
|
||||||
fingerprint: "SHA256:r",
|
fingerprint: "SHA256:r",
|
||||||
};
|
};
|
||||||
const ED = { ...KEY, type: "ed25519" as const };
|
const ED = { ...KEY, type: "ed25519" as const };
|
||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
|
||||||
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
|
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
|
||||||
);
|
);
|
||||||
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
|
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||||
// No RSA key yet: a plain Generate, no replace prompt, the ED25519 key stays.
|
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
|
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
|
||||||
expect(confirmDialog).not.toHaveBeenCalled();
|
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
// Each section explains only its own host.
|
||||||
expect(await screen.findByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
expect(within(gh).queryByText(/IAM/)).toBeNull();
|
||||||
expect(screen.getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
|
expect(within(aws).queryByText(/GitHub/)).toBeNull();
|
||||||
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
|
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
|
||||||
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
|
||||||
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
|
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
|
||||||
|
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||||
|
const H = "git-codecommit.eu-west-1.amazonaws.com";
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
|
||||||
|
view({ sshKeys: [RSA], sshUsers: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
|
||||||
|
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||||
|
expect(await within(aws).findByText("eu-west-1 · APKA1")).toBeInTheDocument();
|
||||||
|
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
||||||
|
// A CodeCommit host never shows up again under "other hosts".
|
||||||
|
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||||
|
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a user for any other SSH host under the advanced section", async () => {
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY, sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
await userEvent.click(await screen.findByText("Other SSH hosts (advanced)"));
|
||||||
|
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
|
||||||
|
await userEvent.type(screen.getByLabelText("User name"), "deploy");
|
||||||
|
const form = screen.getByLabelText("Host").closest("form")!;
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
|
host: "git.example.com",
|
||||||
|
user: "deploy",
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("warns while the commit author is unset and saves it", async () => {
|
it("warns while the commit author is unset and saves it", async () => {
|
||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
||||||
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberTmuxCard.tsx
|
||||||
|
* @description The Users page's tmux card (one Linux user per account only,
|
||||||
|
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
||||||
|
* beside the installed one, its sessions, whether an agent of theirs is
|
||||||
|
* working — so an admin sees who still runs an old tmux after an upgrade.
|
||||||
|
* An admin restarts one member's server, or every idle outdated one at once
|
||||||
|
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||||
|
* time with their own password again; a restart ends that member's panes,
|
||||||
|
* and the next start runs the installed tmux. An analyst only reads.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
||||||
|
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
|
||||||
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||||
|
const err = e as Error & { code?: string };
|
||||||
|
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||||
|
? t(`tmux.errors.${err.code}`)
|
||||||
|
: err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||||
|
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||||
|
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||||
|
|
||||||
|
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [data, setData] = useState<MemberTmux | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// A member row = restart that one; "idle" = every idle outdated member.
|
||||||
|
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
() =>
|
||||||
|
api.users
|
||||||
|
.tmux()
|
||||||
|
.then((r) => {
|
||||||
|
setData(r);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!data?.available) return null;
|
||||||
|
const rows = data.users.filter((u) => u.running);
|
||||||
|
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
||||||
|
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card" data-testid="member-tmux">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
|
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
|
title={t("tmux.refresh")}
|
||||||
|
aria-label={t("tmux.refresh")}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{isAdmin && idleOutdated.length > 0 && (
|
||||||
|
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
||||||
|
<RotateCcw className="w-4 h-4" />{" "}
|
||||||
|
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!data.matched && (
|
||||||
|
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
||||||
|
{t("tmux.notMatched")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[560px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className={th}>{t("tmux.user")}</th>
|
||||||
|
<th className={th}>{t("tmux.version")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
||||||
|
<th className={th}>{t("tmux.agents")}</th>
|
||||||
|
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
||||||
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||||
|
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
||||||
|
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||||
|
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||||
|
{u.startedAt && (
|
||||||
|
<div className="text-[11px] text-fg-muted">
|
||||||
|
{t("tmux.since", {
|
||||||
|
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||||
|
{u.sessions ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||||
|
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-5 py-2 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRestarting(u)}
|
||||||
|
title={t("tmux.restart", { name: u.username })}
|
||||||
|
aria-label={t("tmux.restart", { name: u.username })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{restarting && (
|
||||||
|
<RestartDialog
|
||||||
|
target={restarting}
|
||||||
|
idle={idleOutdated}
|
||||||
|
onClose={() => setRestarting(null)}
|
||||||
|
onDone={(msg) => {
|
||||||
|
toast.success(msg);
|
||||||
|
setRestarting(null);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestartDialog({
|
||||||
|
target,
|
||||||
|
idle,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
target: MemberTmuxRow | "idle";
|
||||||
|
idle: MemberTmuxRow[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const one = target === "idle" ? null : target;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!current || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (one) {
|
||||||
|
await api.users.tmuxRestart(one.id, current);
|
||||||
|
onDone(t("tmux.done", { name: one.username }));
|
||||||
|
} else {
|
||||||
|
const r = await api.users.tmuxRestartIdle(current);
|
||||||
|
// A server that would not stop needs root: say which, the rest went.
|
||||||
|
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||||
|
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||||
|
message={
|
||||||
|
one
|
||||||
|
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||||
|
name: one.username,
|
||||||
|
count: one.sessions ?? 0,
|
||||||
|
})
|
||||||
|
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||||
|
}
|
||||||
|
confirmLabel={t("tmux.restartButton")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!current}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberTmuxCard.test.tsx
|
||||||
|
* @description The Users page's members' tmux card: hidden without one Linux
|
||||||
|
* user per account, lists each running member server with its version and a
|
||||||
|
* badge when it is older than the installed tmux, warns until tmux-member is
|
||||||
|
* installed, and an admin restarts one member — or every idle outdated one,
|
||||||
|
* the working member left out — only with their own password typed; an
|
||||||
|
* analyst sees no restart control.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { MemberTmux } from "../../../lib/api";
|
||||||
|
|
||||||
|
const users = vi.hoisted(() => ({
|
||||||
|
tmux: vi.fn(),
|
||||||
|
tmuxRestart: vi.fn(),
|
||||||
|
tmuxRestartIdle: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return { ...actual, api: { users } };
|
||||||
|
});
|
||||||
|
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { MemberTmuxCard } from "../MemberTmuxCard";
|
||||||
|
|
||||||
|
const DATA: MemberTmux = {
|
||||||
|
available: true,
|
||||||
|
installed: "3.8",
|
||||||
|
matched: true,
|
||||||
|
users: [
|
||||||
|
{ id: 2, username: "mia", running: true, version: "3.6", sessions: 2, busy: 0, outdated: true },
|
||||||
|
{ id: 3, username: "zed", running: true, version: "3.6", sessions: 1, busy: 1, outdated: true },
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
username: "ann",
|
||||||
|
running: true,
|
||||||
|
version: "3.8",
|
||||||
|
sessions: 3,
|
||||||
|
busy: 0,
|
||||||
|
outdated: false,
|
||||||
|
},
|
||||||
|
{ id: 5, username: "bob", running: false, busy: 0, outdated: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
users.tmux.mockResolvedValue(DATA);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MemberTmuxCard", () => {
|
||||||
|
it("renders nothing without one Linux user per account", async () => {
|
||||||
|
users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||||
|
const { container } = render(<MemberTmuxCard isAdmin />);
|
||||||
|
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists running servers and marks the ones older than the installed tmux", async () => {
|
||||||
|
render(<MemberTmuxCard isAdmin={false} />);
|
||||||
|
expect(await screen.findByText("mia")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("bob")).toBeNull();
|
||||||
|
expect(screen.getAllByText("older than installed")).toHaveLength(2);
|
||||||
|
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||||
|
// An analyst reads only.
|
||||||
|
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns until tmux-member is installed", async () => {
|
||||||
|
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
||||||
|
render(<MemberTmuxCard isAdmin />);
|
||||||
|
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts one member only with the admin's password", async () => {
|
||||||
|
users.tmuxRestart.mockResolvedValue({});
|
||||||
|
render(<MemberTmuxCard isAdmin />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||||
|
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||||
|
const go = screen.getByRole("button", { name: "Restart" });
|
||||||
|
expect(go).toBeDisabled();
|
||||||
|
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||||
|
await userEvent.click(go);
|
||||||
|
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||||
|
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
|
||||||
|
render(<MemberTmuxCard isAdmin />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||||
|
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
|
||||||
|
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -901,8 +901,8 @@
|
|||||||
"title": "Your git identity",
|
"title": "Your git identity",
|
||||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||||
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
|
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
|
||||||
"sshTitle": "SSH key",
|
"sshTitle": "SSH keys",
|
||||||
"generate": "Generate SSH key",
|
"generate": "Generate key",
|
||||||
"replace": "Replace key",
|
"replace": "Replace key",
|
||||||
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
|
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
|
||||||
"keyMade": "SSH key ready — add its public key to your git host.",
|
"keyMade": "SSH key ready — add its public key to your git host.",
|
||||||
@@ -910,17 +910,34 @@
|
|||||||
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
||||||
"publicKey": "Public key",
|
"publicKey": "Public key",
|
||||||
"copy": "Copy public key",
|
"copy": "Copy public key",
|
||||||
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only, and clone as ssh://<SSH key ID>@git-codecommit…, not git@). Paste the whole line as one line. The private key never leaves the server.",
|
"noKey": "No key yet. Generate one to start.",
|
||||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
|
||||||
"httpsTitle": "HTTPS logins",
|
"httpsTitle": "HTTPS logins",
|
||||||
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
||||||
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
|
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
|
||||||
"keyType": "Key type",
|
|
||||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
|
||||||
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)",
|
|
||||||
"terminal": "Use this key for git in the terminal",
|
"terminal": "Use this key for git in the terminal",
|
||||||
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
||||||
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal."
|
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
||||||
|
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
||||||
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
|
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||||
|
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||||
|
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
||||||
|
"awsTitle": "AWS CodeCommit",
|
||||||
|
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||||
|
"stepCopy": "Copy the public key.",
|
||||||
|
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
|
||||||
|
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
|
||||||
|
"awsKeyId": "SSH key ID",
|
||||||
|
"awsRegion": "Region",
|
||||||
|
"awsSave": "Save",
|
||||||
|
"awsSaved": "SSH key ID saved for {{region}}",
|
||||||
|
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
|
||||||
|
"otherTitle": "Other SSH hosts (advanced)",
|
||||||
|
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||||
|
"otherHost": "Host",
|
||||||
|
"otherUser": "User name",
|
||||||
|
"otherSaved": "SSH user saved for {{host}}",
|
||||||
|
"privateNote": "Only the public key is ever shown; the private key never leaves the server."
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
|
|||||||
@@ -204,5 +204,35 @@
|
|||||||
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
||||||
"confirm": "Delete for good",
|
"confirm": "Delete for good",
|
||||||
"needsPassword": "Enter your own password below first."
|
"needsPassword": "Enter your own password below first."
|
||||||
|
},
|
||||||
|
"tmux": {
|
||||||
|
"title": "Members' tmux",
|
||||||
|
"description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"notMatched": "Run `sudo agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
|
||||||
|
"empty": "No member has a tmux server running.",
|
||||||
|
"user": "User",
|
||||||
|
"version": "tmux",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"agents": "Agents",
|
||||||
|
"stuck": "not answering",
|
||||||
|
"outdated": "older than installed",
|
||||||
|
"since": "since {{at}}",
|
||||||
|
"working_one": "{{count}} working",
|
||||||
|
"working_other": "{{count}} working",
|
||||||
|
"idle": "idle",
|
||||||
|
"restart": "Restart {{name}}'s tmux",
|
||||||
|
"restartIdle_one": "Restart {{count}} idle member",
|
||||||
|
"restartIdle_other": "Restart {{count}} idle members",
|
||||||
|
"restartIdleTitle": "Restart idle members' tmux",
|
||||||
|
"restartHint": "Every pane of {{name}} closes ({{count}} sessions). Their Claude sessions resume from the workspace, and the next start runs the installed tmux.",
|
||||||
|
"restartHintBusy": "An agent of {{name}} is working right now and will be stopped. Every pane of theirs closes ({{count}} sessions); their Claude sessions resume from the workspace.",
|
||||||
|
"restartIdleHint": "Restarts the tmux of every member on an older version with no agent working: {{names}}. Their panes close; their Claude sessions resume from the workspace.",
|
||||||
|
"restartButton": "Restart",
|
||||||
|
"done": "{{name}}'s tmux restarted",
|
||||||
|
"doneIdle": "Restarted: {{names}}",
|
||||||
|
"errors": {
|
||||||
|
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -902,7 +902,7 @@
|
|||||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||||
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
|
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
|
||||||
"sshTitle": "SSH key",
|
"sshTitle": "SSH key",
|
||||||
"generate": "Tạo SSH key",
|
"generate": "Tạo key",
|
||||||
"replace": "Thay key",
|
"replace": "Thay key",
|
||||||
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
|
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
|
||||||
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
||||||
@@ -910,17 +910,34 @@
|
|||||||
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
||||||
"publicKey": "Public key",
|
"publicKey": "Public key",
|
||||||
"copy": "Copy public key",
|
"copy": "Copy public key",
|
||||||
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA, và clone bằng ssh://<SSH key ID>@git-codecommit…, không phải git@). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
|
"noKey": "Chưa có key. Bấm Tạo key để bắt đầu.",
|
||||||
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
|
||||||
"httpsTitle": "Đăng nhập HTTPS",
|
"httpsTitle": "Đăng nhập HTTPS",
|
||||||
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
||||||
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
|
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
|
||||||
"keyType": "Loại key",
|
|
||||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
|
||||||
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)",
|
|
||||||
"terminal": "Dùng key này cho git trong terminal",
|
"terminal": "Dùng key này cho git trong terminal",
|
||||||
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
||||||
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal."
|
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
||||||
|
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
||||||
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
|
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||||
|
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||||
|
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
||||||
|
"awsTitle": "AWS CodeCommit",
|
||||||
|
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||||
|
"stepCopy": "Copy public key.",
|
||||||
|
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
|
||||||
|
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
|
||||||
|
"awsKeyId": "SSH key ID",
|
||||||
|
"awsRegion": "Region",
|
||||||
|
"awsSave": "Lưu",
|
||||||
|
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||||
|
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
|
||||||
|
"otherTitle": "Host SSH khác (nâng cao)",
|
||||||
|
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||||
|
"otherHost": "Host",
|
||||||
|
"otherUser": "Tên user",
|
||||||
|
"otherSaved": "Đã lưu SSH user cho {{host}}",
|
||||||
|
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server."
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
|
|||||||
@@ -204,5 +204,35 @@
|
|||||||
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
|
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
|
||||||
"confirm": "Xoá vĩnh viễn",
|
"confirm": "Xoá vĩnh viễn",
|
||||||
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
|
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
|
||||||
|
},
|
||||||
|
"tmux": {
|
||||||
|
"title": "Tmux của member",
|
||||||
|
"description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.",
|
||||||
|
"refresh": "Làm mới",
|
||||||
|
"notMatched": "Chạy `sudo agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
|
||||||
|
"empty": "Không member nào đang chạy tmux server.",
|
||||||
|
"user": "Người dùng",
|
||||||
|
"version": "tmux",
|
||||||
|
"sessions": "Session",
|
||||||
|
"agents": "Agent",
|
||||||
|
"stuck": "không trả lời",
|
||||||
|
"outdated": "cũ hơn bản cài",
|
||||||
|
"since": "từ {{at}}",
|
||||||
|
"working_one": "{{count}} đang chạy",
|
||||||
|
"working_other": "{{count}} đang chạy",
|
||||||
|
"idle": "rảnh",
|
||||||
|
"restart": "Khởi động lại tmux của {{name}}",
|
||||||
|
"restartIdle_one": "Khởi động lại {{count}} member đang rảnh",
|
||||||
|
"restartIdle_other": "Khởi động lại {{count}} member đang rảnh",
|
||||||
|
"restartIdleTitle": "Khởi động lại tmux của member đang rảnh",
|
||||||
|
"restartHint": "Mọi pane của {{name}} sẽ đóng ({{count}} session). Phiên Claude của họ resume lại được từ workspace, và lần Start sau sẽ chạy tmux bản đã cài.",
|
||||||
|
"restartHintBusy": "Agent của {{name}} đang chạy và sẽ bị dừng. Mọi pane của họ sẽ đóng ({{count}} session); phiên Claude resume lại được từ workspace.",
|
||||||
|
"restartIdleHint": "Khởi động lại tmux của mọi member đang chạy bản cũ mà không có agent nào đang chạy: {{names}}. Pane của họ sẽ đóng; phiên Claude resume lại được từ workspace.",
|
||||||
|
"restartButton": "Khởi động lại",
|
||||||
|
"done": "Đã khởi động lại tmux của {{name}}",
|
||||||
|
"doneIdle": "Đã khởi động lại: {{names}}",
|
||||||
|
"errors": {
|
||||||
|
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,6 +544,31 @@ export type ServerUserUsage = AuthUser & {
|
|||||||
logins?: number;
|
logins?: number;
|
||||||
lastLoginAt?: string | null;
|
lastLoginAt?: string | null;
|
||||||
};
|
};
|
||||||
|
/** One member's own tmux server (`GET /api/users/tmux`). */
|
||||||
|
export interface MemberTmuxRow {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
running: boolean;
|
||||||
|
/** Did not answer (hung, or backed off). */
|
||||||
|
stuck?: boolean;
|
||||||
|
version?: string | null;
|
||||||
|
pid?: number | null;
|
||||||
|
startedAt?: number | null;
|
||||||
|
sessions?: number | null;
|
||||||
|
/** Workspaces of theirs with an agent working now. */
|
||||||
|
busy: number;
|
||||||
|
/** Runs an older tmux than the installed one. */
|
||||||
|
outdated: boolean;
|
||||||
|
}
|
||||||
|
/** `GET /api/users/tmux`: `available` only with one Linux user per account. */
|
||||||
|
export interface MemberTmux {
|
||||||
|
available: boolean;
|
||||||
|
installed?: string | null;
|
||||||
|
/** Calls on an older server already use its own binary (tmux-member installed). */
|
||||||
|
matched?: boolean;
|
||||||
|
users: MemberTmuxRow[];
|
||||||
|
}
|
||||||
|
|
||||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||||
export type UserStorage = {
|
export type UserStorage = {
|
||||||
bytes: number;
|
bytes: number;
|
||||||
@@ -915,6 +940,24 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ ...body, currentPassword }),
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
}),
|
}),
|
||||||
|
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
|
||||||
|
tmux: () => request<MemberTmux>("/users/tmux"),
|
||||||
|
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
|
||||||
|
tmuxRestart: (id: number, currentPassword: string) =>
|
||||||
|
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currentPassword }),
|
||||||
|
}),
|
||||||
|
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
|
||||||
|
tmuxRestartIdle: (currentPassword: string) =>
|
||||||
|
request<{
|
||||||
|
restarted: string[];
|
||||||
|
skipped: string[];
|
||||||
|
failed: { username: string; message: string }[];
|
||||||
|
}>("/users/tmux-restart-idle", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currentPassword }),
|
||||||
|
}),
|
||||||
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
|
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
|
||||||
get: (id: number) => request<UserDetail>(`/users/${id}`),
|
get: (id: number) => request<UserDetail>(`/users/${id}`),
|
||||||
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
||||||
@@ -2676,6 +2719,11 @@ export const api = {
|
|||||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||||
setTerminalSsh: (on: boolean) =>
|
setTerminalSsh: (on: boolean) =>
|
||||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||||
|
/** PUT /api/me/git/ssh-user — the SSH login name for a host (CodeCommit: the SSH key ID). */
|
||||||
|
setSshUser: (body: { host: string; user: string }) =>
|
||||||
|
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
removeSshUser: (host: string) =>
|
||||||
|
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||||
removeHttps: (host: string) =>
|
removeHttps: (host: string) =>
|
||||||
@@ -4210,6 +4258,8 @@ export interface GitIdentity {
|
|||||||
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
|
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
|
||||||
/** The terminal switch (shared by both keys); null without a key. */
|
/** The terminal switch (shared by both keys); null without a key. */
|
||||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||||
|
/** SSH login name per host (`url.<…>.insteadOf` in `~/.gitconfig`); absent from an older server. */
|
||||||
|
sshUsers?: { host: string; user: string }[];
|
||||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||||
author: { name: string | null; email: string | null };
|
author: { name: string | null; email: string | null };
|
||||||
https: {
|
https: {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||||
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
|
* on (`AccountPoolCard`), and (one Linux user per account) each member's own
|
||||||
|
* tmux server beside the installed version, restartable by an admin
|
||||||
|
* (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the
|
||||||
* server refuses without it); an analyst only reads. A row shows the display
|
* server refuses without it); an analyst only reads. A row shows the display
|
||||||
* name and email under the username when set.
|
* name and email under the username when set.
|
||||||
* Laid out like the other list pages: icon header, a row of totals
|
* Laid out like the other list pages: icon header, a row of totals
|
||||||
@@ -48,6 +50,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
|
|||||||
import { StatCard } from "../components/StatCard";
|
import { StatCard } from "../components/StatCard";
|
||||||
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||||
|
import { MemberTmuxCard } from "../components/users/MemberTmuxCard";
|
||||||
import { TableRowSkeleton } from "../components/Skeleton";
|
import { TableRowSkeleton } from "../components/Skeleton";
|
||||||
|
|
||||||
type Role = AuthUser["role"];
|
type Role = AuthUser["role"];
|
||||||
@@ -296,6 +299,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccountPoolCard isAdmin={isAdmin} />
|
<AccountPoolCard isAdmin={isAdmin} />
|
||||||
|
<MemberTmuxCard isAdmin={isAdmin} />
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const users = vi.hoisted(() => ({
|
|||||||
setPassword: vi.fn(),
|
setPassword: vi.fn(),
|
||||||
assignWorkspace: vi.fn(),
|
assignWorkspace: vi.fn(),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
|
// No S3 here: the members' tmux card stays hidden.
|
||||||
|
tmux: vi.fn(async () => ({ available: false, users: [] })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
|||||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.1 (rebuild client to match)
|
UI build v2.8.4 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -85,7 +85,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
|||||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||||
|
|
||||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
@@ -1046,6 +1046,8 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
|
|||||||
POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape
|
POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape
|
||||||
DELETE /api/me/git/ssh-key Delete the key of `?type=ed25519|rsa`, or every key without it (404 ENOTFOUND, 400 EBADKEYTYPE) — also removes our terminal `core.sshCommand`
|
DELETE /api/me/git/ssh-key Delete the key of `?type=ed25519|rsa`, or every key without it (404 ENOTFOUND, 400 EBADKEYTYPE) — also removes our terminal `core.sshCommand`
|
||||||
PUT /api/me/git/ssh-terminal Use the key for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (ours first, then `~/.ssh/id_*`); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
PUT /api/me/git/ssh-terminal Use the key for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (ours first, then `~/.ssh/id_*`); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||||
|
PUT /api/me/git/ssh-user The SSH login name for one host — Body: { host, user } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` to the account's `~/.gitconfig` (replacing ours for that host), so a plain `ssh://<host>/…` URL logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`); 400 EBADHOST / EBADSSHUSER. Listed as `sshUsers: [{host, user}]` in GET /api/me/git
|
||||||
|
DELETE /api/me/git/ssh-user/:host Drop that rule (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||||
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
||||||
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
||||||
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
|
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
|
||||||
|
|||||||
+30
-6
@@ -544,9 +544,9 @@ The pieces, and what each is for (the install steps are in the
|
|||||||
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
|
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
|
||||||
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
|
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
|
||||||
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
|
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
|
||||||
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
|
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/tmux-member`, `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
|
||||||
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
|
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
|
||||||
| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
|
| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
|
||||||
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
|
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
|
||||||
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
|
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
|
||||||
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
|
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
|
||||||
@@ -562,7 +562,7 @@ What runs as the member, and how:
|
|||||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one section per kind of host, each with only its own steps: **GitHub · GitLab · Gitea · Bitbucket** (ED25519) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), plus a collapsed **Other SSH hosts (advanced)**. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||||
- the workspace prompt and its context files;
|
- the workspace prompt and its context files;
|
||||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||||
@@ -675,6 +675,29 @@ a pane really is. Each case below is refused or bounded, with a test in
|
|||||||
click makes on a member's pane (start, type, stop) still waits, up to the
|
click makes on a member's pane (start, type, stop) still waits, up to the
|
||||||
3 s. Every other synchronous call
|
3 s. Every other synchronous call
|
||||||
that runs as a member (git in their folder, `write-as`) stops after 5 s.
|
that runs as a member (git in their folder, `write-as`) stops after 5 s.
|
||||||
|
- **The host's own tmux server is bounded too.** Every call on it stops
|
||||||
|
after 5 s, and one that timed out is not asked again for a minute
|
||||||
|
(`host: tmux server not answering` meanwhile), so a spinning server can no
|
||||||
|
longer freeze the dashboard (it once did for five minutes). Every call on a
|
||||||
|
running host server uses that server's own binary (`/proc/<pid>/exe`, found
|
||||||
|
from its socket in `/proc`), so installing a newer tmux under a running
|
||||||
|
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
|
||||||
|
100% CPU.
|
||||||
|
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
||||||
|
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
||||||
|
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
||||||
|
found from their socket), else with `TMUX_BIN`, so a newer tmux never asks
|
||||||
|
an older server. Until `sudo agenthub server install-system` has installed
|
||||||
|
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
|
||||||
|
version until their last pane closes; the next start runs `TMUX_BIN`.
|
||||||
|
- **Users → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||||
|
running server with its version beside the installed one (an *older than
|
||||||
|
installed* badge), its sessions and whether an agent of theirs is working.
|
||||||
|
**Restart** ends one member's server — every pane of theirs closes, their
|
||||||
|
Claude sessions resume from the workspace — and **Restart N idle members**
|
||||||
|
does it for every outdated member with no agent working; both take the
|
||||||
|
admin's password again and are audited per member. A server that will not
|
||||||
|
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
|
||||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||||
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
|
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
|
||||||
a whole member file is read only up to a cap (1 MiB for a login or a
|
a whole member file is read only up to a cap (1 MiB for a login or a
|
||||||
@@ -736,10 +759,11 @@ itself, once.
|
|||||||
|
|
||||||
### After an update that changes the member helpers
|
### After an update that changes the member helpers
|
||||||
|
|
||||||
`write-as`, `pane-launch` and the sudoers file are copied to
|
`write-as`, `pane-launch`, `tmux-member` and the sudoers file are copied to
|
||||||
`/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does
|
`/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does
|
||||||
not touch root-owned files. When a release changes them (v2.7.7: `write-as`
|
not touch root-owned files. When a release changes them (v2.7.7: `write-as`
|
||||||
learned `move`/`copy` for the File Explorer), re-run it once:
|
learned `move`/`copy` for the File Explorer; v2.8.4 added `tmux-member`),
|
||||||
|
re-run it once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo agenthub server install-system
|
sudo agenthub server install-system
|
||||||
@@ -811,7 +835,7 @@ again changes nothing that is already in place):
|
|||||||
host account's);
|
host account's);
|
||||||
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
|
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
|
||||||
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
|
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
|
||||||
4. installs `agenthub-provision`, `write-as` and `pane-launch` from
|
4. installs `agenthub-provision`, `write-as`, `pane-launch` and `tmux-member` from
|
||||||
`scripts/server/`;
|
`scripts/server/`;
|
||||||
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
|
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
|
||||||
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
|
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.1",
|
"version": "2.8.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.1",
|
"version": "2.8.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.1",
|
"version": "2.8.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
||||||
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
||||||
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
||||||
|
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
|
||||||
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
||||||
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
||||||
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
||||||
@@ -105,6 +106,7 @@ chmod 0644 /etc/agenthub/server.conf
|
|||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
||||||
|
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
||||||
|
|
||||||
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
||||||
NODE_VER=$("$NODE" -p process.version)
|
NODE_VER=$("$NODE" -p process.version)
|
||||||
@@ -138,7 +140,7 @@ visudo -cf "$TMP_SUDOERS" >/dev/null 2>&1 && NOTTY="Defaults:$HOST !requiretty"
|
|||||||
{
|
{
|
||||||
echo "# AgentHub server mode (install-system). Members are never in sudoers."
|
echo "# AgentHub server mode (install-system). Members are never in sudoers."
|
||||||
[ -z "$NOTTY" ] || echo "$NOTTY"
|
[ -z "$NOTTY" ] || echo "$NOTTY"
|
||||||
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
|
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, /opt/agenthub/libexec/tmux-member, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
|
||||||
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
|
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
|
||||||
for a in start stop restart enable disable; do
|
for a in start stop restart enable disable; do
|
||||||
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
|
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# @file tmux-member.sh
|
||||||
|
# @description Server mode, one Linux user per account (S3): installed as
|
||||||
|
# /opt/agenthub/libexec/tmux-member, the one tmux the host may run AS a
|
||||||
|
# member (sudoers). Called as `tmux-member -S <socket> <tmux args…>`, it runs
|
||||||
|
# the args with the binary of the tmux server already listening on that
|
||||||
|
# socket (`/proc/<pid>/exe`, which still runs after an upgrade replaced the
|
||||||
|
# file), else with TMUX_BIN from /etc/agenthub/server.conf (no server yet:
|
||||||
|
# this call starts one, on the installed version). A client must match its
|
||||||
|
# server: a tmux 3.8 client asking a 3.6 server kept it at 100% CPU.
|
||||||
|
#
|
||||||
|
# It runs as the member and execs only a process THEY own (under hidepid they
|
||||||
|
# see no other), so it can run nothing they could not run themselves.
|
||||||
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
set -u
|
||||||
|
bin=$(sed -n 's/^TMUX_BIN=//p' /etc/agenthub/server.conf 2>/dev/null | head -n 1)
|
||||||
|
[[ "$bin" == /* ]] || { echo "tmux-member: no TMUX_BIN in /etc/agenthub/server.conf" >&2; exit 2; }
|
||||||
|
if [ "${1:-}" = -S ] && [ -n "${2:-}" ]; then
|
||||||
|
# /proc/net/unix: Num RefCount Protocol Flags Type St Inode Path; 00010000 = listening.
|
||||||
|
inode=$(awk -v s="$2" '$4 == "00010000" && $8 == s { print $7; exit }' /proc/net/unix 2>/dev/null)
|
||||||
|
if [ -n "$inode" ]; then
|
||||||
|
for d in /proc/[0-9]*; do
|
||||||
|
[ -O "$d" ] || continue
|
||||||
|
read -r comm <"$d/comm" 2>/dev/null || continue
|
||||||
|
[[ "$comm" == tmux* ]] || continue
|
||||||
|
for fd in "$d"/fd/*; do
|
||||||
|
if [ "$(readlink "$fd" 2>/dev/null)" = "socket:[$inode]" ] && [ -x "$d/exe" ]; then
|
||||||
|
exec "$d/exe" "$@"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exec "$bin" "$@"
|
||||||
@@ -393,6 +393,56 @@ describe("/api/me/git/ssh-terminal (issue #82)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||||
|
const getUrl = (url) =>
|
||||||
|
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
|
||||||
|
const HOST = "git-codecommit.ap-southeast-1.amazonaws.com";
|
||||||
|
|
||||||
|
it("makes a plain ssh:// URL log in as the saved user, and only for that host", async () => {
|
||||||
|
const r = await request("PUT", "/api/me/git/ssh-user", {
|
||||||
|
host: ` ${HOST.toUpperCase()} `,
|
||||||
|
user: "APKAEXAMPLE1",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
|
||||||
|
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
|
||||||
|
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
|
||||||
|
// A second save replaces, never stacks a second rewrite.
|
||||||
|
const again = await request("PUT", "/api/me/git/ssh-user", {
|
||||||
|
host: HOST,
|
||||||
|
user: "APKAEXAMPLE2",
|
||||||
|
});
|
||||||
|
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
|
||||||
|
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
|
||||||
|
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
|
||||||
|
assert.deepEqual(del.body.sshUsers, []);
|
||||||
|
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://${HOST}/v1/repos/x`);
|
||||||
|
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${HOST}`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bad host or user, and leaves the user's own insteadOf alone", async () => {
|
||||||
|
const own = "url.ssh://me@mine.test/.insteadOf";
|
||||||
|
execFileSync("git", ["config", "--global", own, "ssh://mine.test/"]);
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
(await request("PUT", "/api/me/git/ssh-user", { host: "-oProxy", user: "a" })).body.error
|
||||||
|
.code,
|
||||||
|
"EBADHOST"
|
||||||
|
);
|
||||||
|
const bad = await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "a@b" });
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
assert.equal(bad.body.error.code, "EBADSSHUSER");
|
||||||
|
// Ours lists only our shape; a rule the user wrote for another host is kept.
|
||||||
|
const r = await request("GET", "/api/me/git");
|
||||||
|
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
|
||||||
|
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
|
||||||
|
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
|
||||||
|
} finally {
|
||||||
|
execFileSync("git", ["config", "--global", "--unset", own]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("git-identity, member account (S3, stubbed)", () => {
|
describe("git-identity, member account (S3, stubbed)", () => {
|
||||||
const runAs = require("../lib/run-as");
|
const runAs = require("../lib/run-as");
|
||||||
const worktree = require("../lib/worktree");
|
const worktree = require("../lib/worktree");
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for each member's own tmux server on the Users page (S3,
|
||||||
|
* server/lib/member-tmux.js + `/api/users/tmux*`): its version beside the
|
||||||
|
* installed one, an admin's restart of one member (password again, audited,
|
||||||
|
* ETMUXSTUCK when it will not stop) and of every idle outdated member — a
|
||||||
|
* member whose agent is working is skipped. Member tmux calls are faked.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "member-tmux-"));
|
||||||
|
const USERS = path.join(TMP, "users");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const linux = require("../lib/linux-accounts");
|
||||||
|
const tmux = require("../lib/tmux");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const ids = {};
|
||||||
|
const BOSS_PASS = "admin-pass-1";
|
||||||
|
|
||||||
|
// Fake member tmux servers, by Linux login.
|
||||||
|
const servers = {};
|
||||||
|
function fakeMember(acct, args) {
|
||||||
|
const s = servers[acct.login];
|
||||||
|
if (!s || !s.alive) throw Object.assign(new Error("no server running"), { status: 1 });
|
||||||
|
if (args[0] === "display-message") return `${s.version}\t${s.pid}\t1700000000\t${s.sessions}\n`;
|
||||||
|
if (args[0] === "kill-server") {
|
||||||
|
if (s.stuck) throw Object.assign(new Error("server exited unexpectedly"), { status: 1 });
|
||||||
|
s.alive = false;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (args[0] === "list-sessions") return "";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(method, p, { cookie, body } = {}) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||||
|
fs.mkdirSync(USERS, { recursive: true });
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||||
|
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||||
|
ids.zed = (await auth.createUser("zed", "member-pass-1", "developer")).id;
|
||||||
|
ids.ann = (await auth.createUser("ann", "member-pass-1", "developer")).id;
|
||||||
|
ids.stu = (await auth.createUser("stu", "member-pass-1", "developer")).id;
|
||||||
|
// zed's agent is working right now.
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'running')"
|
||||||
|
).run(path.join(USERS, "zed", "p"), ids.zed);
|
||||||
|
// S3 on: a server.conf whose TMUX_BIN answers 3.8, and tmux-member installed.
|
||||||
|
const bin = path.join(TMP, "tmux");
|
||||||
|
fs.writeFileSync(bin, "#!/bin/sh\necho 'tmux 3.8'\n", { mode: 0o755 });
|
||||||
|
const wrapper = path.join(TMP, "tmux-member");
|
||||||
|
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||||
|
fs.writeFileSync(
|
||||||
|
process.env.AGENTHUB_SERVER_CONF,
|
||||||
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nTMUX_BIN=${bin}\nGIT_BIN=/usr/bin/git\n`
|
||||||
|
);
|
||||||
|
linux.__setTmuxMember(wrapper);
|
||||||
|
Object.assign(servers, {
|
||||||
|
"ah-mia": { version: "3.6", pid: 11, sessions: 2, alive: true },
|
||||||
|
"ah-zed": { version: "3.6", pid: 12, sessions: 1, alive: true },
|
||||||
|
"ah-ann": { version: "3.8", pid: 13, sessions: 3, alive: true },
|
||||||
|
"ah-stu": { version: "3.6", pid: 14, sessions: 1, alive: true, stuck: true },
|
||||||
|
});
|
||||||
|
tmux.__setAccountImpl(() => null, fakeMember);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
delete process.env.AGENTHUB_USERS_ROOT;
|
||||||
|
delete process.env.AGENTHUB_SERVER_CONF;
|
||||||
|
linux.__setTmuxMember("/opt/agenthub/libexec/tmux-member");
|
||||||
|
linux.__resetConfCache();
|
||||||
|
tmux.__reset();
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("member tmux servers on the Users page", () => {
|
||||||
|
it("lists each member's version beside the installed one, and who is working", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("GET", "/api/users/tmux", { cookie: boss });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.available, true);
|
||||||
|
assert.equal(r.body.installed, "3.8");
|
||||||
|
assert.equal(r.body.matched, true);
|
||||||
|
const by = Object.fromEntries(r.body.users.map((u) => [u.username, u]));
|
||||||
|
assert.equal(by.mia.version, "3.6");
|
||||||
|
assert.equal(by.mia.outdated, true);
|
||||||
|
assert.equal(by.mia.sessions, 2);
|
||||||
|
assert.equal(by.zed.busy, 1);
|
||||||
|
assert.equal(by.ann.outdated, false);
|
||||||
|
// boss (no server of theirs) is listed as not running.
|
||||||
|
assert.equal(by.boss.running, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is staff-only to read and an admin's to restart", async () => {
|
||||||
|
const mia = await login("mia", "member-pass-1");
|
||||||
|
assert.equal((await call("GET", "/api/users/tmux", { cookie: mia })).status, 403);
|
||||||
|
const r = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||||
|
cookie: mia,
|
||||||
|
body: { currentPassword: "member-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
assert.equal(servers["ah-ann"].alive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts one member's server with the admin's password, audited", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const wrong = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: "nope" },
|
||||||
|
});
|
||||||
|
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||||
|
assert.equal(servers["ah-ann"].alive, true);
|
||||||
|
const ok = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(servers["ah-ann"].alive, false);
|
||||||
|
const audit = db
|
||||||
|
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||||
|
.get(ids.ann);
|
||||||
|
assert.match(audit.path, /ann: tmux restarted \(was 3\.8, 3 session/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the pid to kill as root when a server will not stop", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("POST", `/api/users/${ids.stu}/tmux-restart`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
assert.equal(r.body.error.code, "ETMUXSTUCK");
|
||||||
|
assert.match(r.body.error.message, /sudo kill -9 14/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts every idle outdated member, skipping one whose agent works", async () => {
|
||||||
|
servers["ah-ann"] = { version: "3.8", pid: 13, sessions: 1, alive: true };
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("POST", "/api/users/tmux-restart-idle", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(r.body.restarted, ["mia"]);
|
||||||
|
assert.deepEqual(r.body.skipped, ["zed"]);
|
||||||
|
assert.equal(r.body.failed[0].username, "stu");
|
||||||
|
assert.equal(servers["ah-mia"].alive, false);
|
||||||
|
assert.equal(servers["ah-zed"].alive, true);
|
||||||
|
assert.equal(servers["ah-ann"].alive, true); // already on 3.8
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -240,9 +240,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
|
|||||||
process.env.AGENTHUB_SERVER_CONF,
|
process.env.AGENTHUB_SERVER_CONF,
|
||||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
|
||||||
);
|
);
|
||||||
linux.__resetConfCache();
|
linux.__setTmuxMember(path.join(TMP, "no-tmux-member"));
|
||||||
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
|
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
|
||||||
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
|
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
|
||||||
|
// Once install-system put tmux-member there, a member's tmux goes through
|
||||||
|
// it (their running server's own binary); the installed one is still known.
|
||||||
|
const wrapper = path.join(TMP, "tmux-member");
|
||||||
|
fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
linux.__setTmuxMember(wrapper);
|
||||||
|
try {
|
||||||
|
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], wrapper);
|
||||||
|
assert.equal(linux.serverConf().tmuxInstalled, "/opt/x/tmux");
|
||||||
|
} finally {
|
||||||
|
linux.__setTmuxMember(path.join(TMP, "no-tmux-member"));
|
||||||
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
|
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
|
||||||
"/opt/agenthub/libexec/write-as"
|
"/opt/agenthub/libexec/write-as"
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ describe("Linux accounts", () => {
|
|||||||
hostUid: 4242,
|
hostUid: 4242,
|
||||||
usersRoot: "/srv/agenthub/users",
|
usersRoot: "/srv/agenthub/users",
|
||||||
port: 4820,
|
port: 4820,
|
||||||
|
tmuxInstalled: null,
|
||||||
bins: {},
|
bins: {},
|
||||||
});
|
});
|
||||||
assert.deepEqual(linux.accountFor("mia"), {
|
assert.deepEqual(linux.accountFor("mia"), {
|
||||||
@@ -199,7 +200,8 @@ describe("Linux accounts", () => {
|
|||||||
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
|
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
|
||||||
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
|
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
|
||||||
}
|
}
|
||||||
assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/);
|
// `delete` is an action now (Delete user); anything else is still refused.
|
||||||
|
assert.throws(() => linux.provision("purge", "mia"), /unknown provision action/);
|
||||||
assert.deepEqual(argvs, []);
|
assert.deepEqual(argvs, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -344,3 +344,56 @@ describe("tmux wrapper", () => {
|
|||||||
assert.equal(tmux.upgradeAdvice("3.3.2", "freebsd"), null);
|
assert.equal(tmux.upgradeAdvice("3.3.2", "freebsd"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("our own tmux server: bounded and version-matched", () => {
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
beforeEach(() => tmux.__reset());
|
||||||
|
|
||||||
|
it("backs off after a call to our server times out, instead of blocking every poll", () => {
|
||||||
|
let calls = 0;
|
||||||
|
tmux.__setExecImpl(() => {
|
||||||
|
calls++;
|
||||||
|
throw Object.assign(new Error("spawnSync tmux ETIMEDOUT"), { code: "ETIMEDOUT" });
|
||||||
|
});
|
||||||
|
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
|
||||||
|
const after = calls;
|
||||||
|
assert.ok(after >= 1);
|
||||||
|
// Meanwhile nothing reaches tmux: the server is left alone for a minute.
|
||||||
|
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
|
||||||
|
assert.equal(tmux.serverPid(), null);
|
||||||
|
assert.equal(calls, after);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the binary of the server listening on our socket from /proc alone", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmux-proc-"));
|
||||||
|
const sock = "/tmp/tmux-1000/default";
|
||||||
|
fs.mkdirSync(path.join(root, "net"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "net", "unix"),
|
||||||
|
"Num RefCount Protocol Flags Type St Inode Path\n" +
|
||||||
|
`0000: 00000003 00000000 00000000 0001 03 777 ${sock}\n` + // a client's
|
||||||
|
`0000: 00000002 00000000 00010000 0001 01 4242 ${sock}\n` // the listener
|
||||||
|
);
|
||||||
|
const proc = (pid, comm, fds) => {
|
||||||
|
fs.mkdirSync(path.join(root, pid, "fd"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, pid, "comm"), `${comm}\n`);
|
||||||
|
fs.symlinkSync(process.execPath, path.join(root, pid, "exe"));
|
||||||
|
fds.forEach((t, i) => fs.symlinkSync(t, path.join(root, pid, "fd", String(i))));
|
||||||
|
};
|
||||||
|
proc("10", "bash", ["socket:[4242]"]); // not tmux
|
||||||
|
proc("11", "tmux: client", ["socket:[777]"]);
|
||||||
|
proc("12", "tmux: server", ["/dev/null", "socket:[4242]"]);
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
tmux.serverExeFor(sock, { procRoot: root, uid: null }),
|
||||||
|
path.join(root, "12", "exe")
|
||||||
|
);
|
||||||
|
assert.equal(tmux.serverExeFor("/tmp/other", { procRoot: root, uid: null }), null);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||||
* by default for a member, a switch for the host; a user's own is never
|
* by default for a member, a switch for the host; a user's own is never
|
||||||
* overwritten).
|
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit
|
||||||
|
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -468,6 +469,79 @@ async function setTerminalSsh(account, on) {
|
|||||||
return terminalSshState(account);
|
return terminalSshState(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SSH login name per host (`~/.gitconfig` url.<…>.insteadOf) ─────────────
|
||||||
|
|
||||||
|
// AWS CodeCommit logs an SSH key in by its IAM key ID (`APKA…`) as the user
|
||||||
|
// name, so `ssh://git-codecommit…/v1/repos/x` (no user) is refused. One
|
||||||
|
// `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` per host makes the
|
||||||
|
// plain URL work for every git of the account — a clone typed in a pane, the
|
||||||
|
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
|
||||||
|
// user name is no secret (an ID, not a key), so it may cross sudo's argv.
|
||||||
|
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||||
|
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||||
|
|
||||||
|
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
|
||||||
|
async function listSshUsers(account) {
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
const out = await git(homeOf(account), [
|
||||||
|
"config",
|
||||||
|
"--global",
|
||||||
|
"--get-regexp",
|
||||||
|
"^url\\..*\\.insteadof$",
|
||||||
|
]).then(
|
||||||
|
(r) => r.stdout,
|
||||||
|
() => ""
|
||||||
|
);
|
||||||
|
const rows = [];
|
||||||
|
for (const line of out.split("\n")) {
|
||||||
|
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
|
||||||
|
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
|
||||||
|
rows.push({ host: m[2], user: m[1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop our rule for `host` (whatever its user); false when there was none. */
|
||||||
|
async function removeSshUser(account, host) {
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
const h = String(host || "").toLowerCase();
|
||||||
|
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
|
||||||
|
for (const r of rows) {
|
||||||
|
await git(homeOf(account), [
|
||||||
|
"config",
|
||||||
|
"--global",
|
||||||
|
"--fixed-value",
|
||||||
|
"--unset-all",
|
||||||
|
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||||
|
`ssh://${r.host}/`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
|
||||||
|
async function setSshUser(account, { host, user } = {}) {
|
||||||
|
const h = String(host || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const u = String(user || "").trim();
|
||||||
|
if (!HOST_RE.test(h)) {
|
||||||
|
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
|
||||||
|
}
|
||||||
|
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
|
||||||
|
await removeSshUser(account, h);
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
await git(homeOf(account), [
|
||||||
|
"config",
|
||||||
|
"--global",
|
||||||
|
"--add",
|
||||||
|
`url.${insteadOfBase(h, u)}.insteadOf`,
|
||||||
|
`ssh://${h}/`,
|
||||||
|
]);
|
||||||
|
return listSshUsers(account);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -714,6 +788,9 @@ module.exports = {
|
|||||||
terminalSshCommand,
|
terminalSshCommand,
|
||||||
terminalSshState,
|
terminalSshState,
|
||||||
setTerminalSsh,
|
setTerminalSsh,
|
||||||
|
listSshUsers,
|
||||||
|
setSshUser,
|
||||||
|
removeSshUser,
|
||||||
listHttps,
|
listHttps,
|
||||||
saveHttps,
|
saveHttps,
|
||||||
removeHttps,
|
removeHttps,
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ function serverConf() {
|
|||||||
return confCache.value;
|
return confCache.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let TMUX_MEMBER = "/opt/agenthub/libexec/tmux-member";
|
||||||
|
/** Test seam: where tmux-member is looked for. */
|
||||||
|
function __setTmuxMember(p) {
|
||||||
|
TMUX_MEMBER = p;
|
||||||
|
confCache = { at: 0, path: null, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
function readConf(file) {
|
function readConf(file) {
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
@@ -56,16 +63,28 @@ function readConf(file) {
|
|||||||
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
|
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
|
||||||
const usersRoot = get("USERS_ROOT");
|
const usersRoot = get("USERS_ROOT");
|
||||||
if (!path.isAbsolute(usersRoot)) return null;
|
if (!path.isAbsolute(usersRoot)) return null;
|
||||||
|
// A member's tmux runs through tmux-member once install-system put it there
|
||||||
|
// (and in sudoers): it picks their RUNNING server's own binary, so a tmux
|
||||||
|
// upgrade never mixes a new client with an old server. Before that, TMUX_BIN.
|
||||||
|
let tmuxBin = get("TMUX_BIN");
|
||||||
|
try {
|
||||||
|
fs.accessSync(TMUX_MEMBER, fs.constants.X_OK);
|
||||||
|
if (path.isAbsolute(tmuxBin)) tmuxBin = TMUX_MEMBER;
|
||||||
|
} catch {
|
||||||
|
/* not installed yet */
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
hostUser: get("HOST_USER"),
|
hostUser: get("HOST_USER"),
|
||||||
hostUid: Number(get("HOST_UID")),
|
hostUid: Number(get("HOST_UID")),
|
||||||
usersRoot,
|
usersRoot,
|
||||||
port: Number(get("PORT")) || null,
|
port: Number(get("PORT")) || null,
|
||||||
|
// The installed tmux (a new member server starts on it).
|
||||||
|
tmuxInstalled: path.isAbsolute(get("TMUX_BIN")) ? get("TMUX_BIN") : null,
|
||||||
// The absolute paths sudoers lists (sudo would resolve a bare name on its
|
// The absolute paths sudoers lists (sudo would resolve a bare name on its
|
||||||
// own secure_path, which may not be them).
|
// own secure_path, which may not be them).
|
||||||
bins: Object.fromEntries(
|
bins: Object.fromEntries(
|
||||||
[
|
[
|
||||||
["tmux", get("TMUX_BIN")],
|
["tmux", tmuxBin],
|
||||||
["git", get("GIT_BIN")],
|
["git", get("GIT_BIN")],
|
||||||
["gh", get("GH_BIN")],
|
["gh", get("GH_BIN")],
|
||||||
].filter(([, v]) => path.isAbsolute(v))
|
].filter(([, v]) => path.isAbsolute(v))
|
||||||
@@ -154,6 +173,7 @@ function __resetConfCache() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
__resetConfCache,
|
__resetConfCache,
|
||||||
|
__setTmuxMember,
|
||||||
confPath,
|
confPath,
|
||||||
LINUX_NAME_RE,
|
LINUX_NAME_RE,
|
||||||
serverConf,
|
serverConf,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @file member-tmux.js
|
||||||
|
* @description Server mode, one Linux user per account (S3): each member's
|
||||||
|
* own tmux server as the Users page shows it — the version it runs next to
|
||||||
|
* the installed one (`TMUX_BIN`), how many sessions it holds and whether an
|
||||||
|
* agent of theirs is working — and an admin's restart of it. A tmux server
|
||||||
|
* keeps the version it started with until its last session closes, so after
|
||||||
|
* a tmux upgrade an old server lingers; `tmux-member` (install-system) keeps
|
||||||
|
* every call on it matched to its own binary, and this is how an admin moves
|
||||||
|
* a member onto the new version on purpose. A restart ends every pane of
|
||||||
|
* theirs (their Claude sessions resume from the workspace).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const { db } = require("../db");
|
||||||
|
const linux = require("./linux-accounts");
|
||||||
|
const tmux = require("./tmux");
|
||||||
|
|
||||||
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
|
||||||
|
// The installed tmux's version (`3.8`), re-read at most once a minute.
|
||||||
|
let installed = { at: 0, bin: null, version: null };
|
||||||
|
function installedVersion() {
|
||||||
|
const bin = linux.serverConf()?.tmuxInstalled || null;
|
||||||
|
if (installed.bin === bin && Date.now() - installed.at < 60_000) return installed.version;
|
||||||
|
let version = null;
|
||||||
|
if (bin) {
|
||||||
|
try {
|
||||||
|
version =
|
||||||
|
String(execFileSync(bin, ["-V"], { encoding: "utf8", timeout: 3000, stdio: "pipe" }))
|
||||||
|
.trim()
|
||||||
|
.replace(/^tmux\s+/, "") || null;
|
||||||
|
} catch {
|
||||||
|
/* missing or broken: unknown */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installed = { at: Date.now(), bin, version };
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of `userId`'s workspaces have an agent working right now. */
|
||||||
|
function busyCount(userId) {
|
||||||
|
return db
|
||||||
|
.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ? AND status = 'running'")
|
||||||
|
.get(userId).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One user's row; null for an account with no Linux side. */
|
||||||
|
function rowFor(user, installedV) {
|
||||||
|
const acct = linux.accountFor(user.username);
|
||||||
|
if (!acct) return null;
|
||||||
|
const info = tmux.memberServerInfo(acct);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
...info,
|
||||||
|
busy: busyCount(user.id),
|
||||||
|
outdated: !!(info.running && info.version && installedV && info.version !== installedV),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{available, installed, matched, users: [...]}` for the Users page. */
|
||||||
|
function overview(users) {
|
||||||
|
if (!linux.s3Enabled()) return { available: false, users: [] };
|
||||||
|
const conf = linux.serverConf();
|
||||||
|
const v = installedVersion();
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
installed: v,
|
||||||
|
// Whether calls on an older server already use its own binary.
|
||||||
|
matched: conf?.bins?.tmux !== conf?.tmuxInstalled,
|
||||||
|
users: users.map((u) => rowFor(u, v)).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart `user`'s tmux server. `{restarted: false}` when none ran; throws
|
||||||
|
* ETMUXSTUCK (with the pid) when it would not stop — root has to kill it.
|
||||||
|
*/
|
||||||
|
function restart(user) {
|
||||||
|
const acct = linux.accountFor(user.username);
|
||||||
|
if (!acct) throw err("ENOS3", "this account has no Linux user (S3 is not installed)");
|
||||||
|
const before = tmux.memberServerInfo(acct);
|
||||||
|
if (!before.running) return { restarted: false, before };
|
||||||
|
try {
|
||||||
|
tmux.killMemberServer(acct);
|
||||||
|
} catch {
|
||||||
|
/* judged by what is left below */
|
||||||
|
}
|
||||||
|
const after = tmux.memberServerInfo(acct);
|
||||||
|
if (after.running) {
|
||||||
|
const pid = before.pid || after.pid;
|
||||||
|
throw err(
|
||||||
|
"ETMUXSTUCK",
|
||||||
|
`${user.username}'s tmux server did not stop — run \`sudo kill -9 ${pid || "<pid>"}\` on the server`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { restarted: true, before };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { overview, restart, installedVersion };
|
||||||
+165
-11
@@ -27,10 +27,16 @@
|
|||||||
* a member's runs in the background and a poll reads its last answer.
|
* a member's runs in the background and a poll reads its last answer.
|
||||||
* With no member (local mode, S3 not installed, the host's own workspaces)
|
* With no member (local mode, S3 not installed, the host's own workspaces)
|
||||||
* nothing changes.
|
* nothing changes.
|
||||||
|
* Calls on our OWN server are bounded like a member's (5 s, then a minute's
|
||||||
|
* back-off) and run that server's own binary (`/proc/<pid>/exe`, found from
|
||||||
|
* its socket), so a tmux upgraded under it never mixes client and server
|
||||||
|
* versions — a 3.8 client spun a 3.6 server at 100% CPU.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { execFile, execFileSync } = require("node:child_process");
|
const { execFile, execFileSync } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
// Test seam: swap the exec implementation so unit tests never invoke a real
|
// Test seam: swap the exec implementation so unit tests never invoke a real
|
||||||
// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style.
|
// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style.
|
||||||
@@ -40,8 +46,88 @@ const { execFile, execFileSync } = require("node:child_process");
|
|||||||
// journal on every poll while no tmux server is running yet.
|
// journal on every poll while no tmux server is running yet.
|
||||||
// cwd "/": a tmux server is forked from the call that starts it and keeps
|
// cwd "/": a tmux server is forked from the call that starts it and keeps
|
||||||
// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
|
// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
|
||||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
|
// Bounded like a member's call (below): our server can spin too — a 3.6
|
||||||
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
// server asked by a 3.8 client sat at 100% CPU, and one unbounded
|
||||||
|
// `has-session` froze the whole dashboard for five minutes.
|
||||||
|
const HOST_TIMEOUT_MS = 5000;
|
||||||
|
const EXEC_OPTS = {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
cwd: "/",
|
||||||
|
timeout: HOST_TIMEOUT_MS,
|
||||||
|
killSignal: "SIGKILL",
|
||||||
|
maxBuffer: 1 << 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The binary that started the tmux server listening on `socket`
|
||||||
|
* (`/proc/<pid>/exe`, which still runs after an upgrade replaced the file),
|
||||||
|
* or null. A client must match its server: tmux upgraded under a running
|
||||||
|
* server left PATH's newer client talking to the old server, which spun at
|
||||||
|
* 100% CPU on it. Found from /proc alone — asking tmux would BE that call.
|
||||||
|
* `procRoot`/`uid` are test seams.
|
||||||
|
*/
|
||||||
|
function serverExeFor(socket, { procRoot = "/proc", uid = process.getuid?.() } = {}) {
|
||||||
|
let inode = null;
|
||||||
|
try {
|
||||||
|
for (const line of fs.readFileSync(path.join(procRoot, "net", "unix"), "utf8").split("\n")) {
|
||||||
|
// Num RefCount Protocol Flags Type St Inode Path — Flags 00010000 = listening.
|
||||||
|
const f = line.trim().split(/\s+/);
|
||||||
|
if (f.length >= 8 && f[3] === "00010000" && f.slice(7).join(" ") === socket) {
|
||||||
|
inode = f[6];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!inode) return null;
|
||||||
|
const want = `socket:[${inode}]`;
|
||||||
|
for (const pid of fs.readdirSync(procRoot)) {
|
||||||
|
if (!/^\d+$/.test(pid)) continue;
|
||||||
|
const dir = path.join(procRoot, pid);
|
||||||
|
try {
|
||||||
|
if (!fs.readFileSync(path.join(dir, "comm"), "utf8").startsWith("tmux")) continue;
|
||||||
|
if (uid != null && fs.statSync(dir).uid !== uid) continue;
|
||||||
|
for (const fd of fs.readdirSync(path.join(dir, "fd"))) {
|
||||||
|
if (fs.readlinkSync(path.join(dir, "fd", fd)) !== want) continue;
|
||||||
|
const exe = path.join(dir, "exe");
|
||||||
|
fs.accessSync(exe, fs.constants.X_OK);
|
||||||
|
return exe;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* gone, or not ours to read */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Our default socket, as tmux itself picks it (no -L/-S). */
|
||||||
|
function hostSocket() {
|
||||||
|
if (process.env.TMUX) return process.env.TMUX.split(",")[0];
|
||||||
|
const dir = process.env.TMUX_TMPDIR || "/tmp";
|
||||||
|
return path.join(dir, `tmux-${process.getuid()}`, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The binary every call on OUR server runs, re-read every HOST_BIN_TTL_MS.
|
||||||
|
// PATH's tmux when no server runs (that call starts one), off Linux, and for
|
||||||
|
// `-V` (the installed client's version, for the upgrade advice).
|
||||||
|
const HOST_BIN_TTL_MS = 10_000;
|
||||||
|
let hostBin = { at: 0, bin: "tmux" };
|
||||||
|
function hostTmuxBin() {
|
||||||
|
if (process.platform !== "linux") return "tmux";
|
||||||
|
if (Date.now() - hostBin.at < HOST_BIN_TTL_MS) return hostBin.bin;
|
||||||
|
let bin = "tmux";
|
||||||
|
try {
|
||||||
|
bin = serverExeFor(hostSocket()) || "tmux";
|
||||||
|
} catch {
|
||||||
|
/* no /proc: PATH's */
|
||||||
|
}
|
||||||
|
hostBin = { at: Date.now(), bin };
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
let execImpl = (args, opts = EXEC_OPTS) =>
|
||||||
|
execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts);
|
||||||
|
|
||||||
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
||||||
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
|
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
|
||||||
@@ -136,6 +222,21 @@ function memberCall(acct, args, input) {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Our own server under the same back-off: a call that timed out stops every
|
||||||
|
// call on it for MEMBER_BACKOFF_MS (ETMUXHUNG), so a spinning server costs
|
||||||
|
// the event loop one HOST_TIMEOUT_MS per minute, not one per poll.
|
||||||
|
const HOST_ACCT = { login: "host" };
|
||||||
|
function hostCall(args) {
|
||||||
|
reachable(HOST_ACCT);
|
||||||
|
try {
|
||||||
|
return execImpl(args);
|
||||||
|
} catch (err) {
|
||||||
|
noteHang(HOST_ACCT, err);
|
||||||
|
if (err.code === "ETIMEDOUT") hostBin.at = 0; // look for the server again
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Test seam. A sync `member` fake also answers the async listing. */
|
/** Test seam. A sync `member` fake also answers the async listing. */
|
||||||
function __setAccountImpl(fn, member) {
|
function __setAccountImpl(fn, member) {
|
||||||
accountImpl = fn;
|
accountImpl = fn;
|
||||||
@@ -159,7 +260,7 @@ function accountOf(name) {
|
|||||||
/** Run a tmux command on the server that holds session `name`. */
|
/** Run a tmux command on the server that holds session `name`. */
|
||||||
function onServerOf(name, args, input) {
|
function onServerOf(name, args, input) {
|
||||||
const acct = accountOf(name);
|
const acct = accountOf(name);
|
||||||
return acct ? memberCall(acct, args, input) : execImpl(args);
|
return acct ? memberCall(acct, args, input) : hostCall(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether this tmux takes `=name` targets: true for tmux itself, false for
|
// Whether this tmux takes `=name` targets: true for tmux itself, false for
|
||||||
@@ -174,12 +275,15 @@ function __setExecImpl(fn, { exact = true } = {}) {
|
|||||||
exactTargets = exact;
|
exactTargets = exact;
|
||||||
listed = new Map();
|
listed = new Map();
|
||||||
listGen++;
|
listGen++;
|
||||||
|
unreachable = new Map();
|
||||||
}
|
}
|
||||||
function __setScopeImpl(fn) {
|
function __setScopeImpl(fn) {
|
||||||
scopeImpl = fn;
|
scopeImpl = fn;
|
||||||
}
|
}
|
||||||
function __reset() {
|
function __reset() {
|
||||||
execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
execImpl = (args, opts = EXEC_OPTS) =>
|
||||||
|
execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts);
|
||||||
|
hostBin = { at: 0, bin: "tmux" };
|
||||||
scopeImpl = realScopeImpl;
|
scopeImpl = realScopeImpl;
|
||||||
accountImpl = (name) => require("./run-as").accountForSession(name);
|
accountImpl = (name) => require("./run-as").accountForSession(name);
|
||||||
memberImpl = realMemberImpl;
|
memberImpl = realMemberImpl;
|
||||||
@@ -217,7 +321,7 @@ function listOutput(acct = null) {
|
|||||||
return hit.out;
|
return hit.out;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const out = sessionNames(execImpl(LIST_ARGS));
|
const out = sessionNames(hostCall(LIST_ARGS));
|
||||||
listed.set("", { at: Date.now(), out });
|
listed.set("", { at: Date.now(), out });
|
||||||
return out;
|
return out;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -324,7 +428,7 @@ function exact() {
|
|||||||
if (exactTargets !== null) return exactTargets;
|
if (exactTargets !== null) return exactTargets;
|
||||||
let first;
|
let first;
|
||||||
try {
|
try {
|
||||||
first = sessionNames(execImpl(LIST_ARGS))
|
first = sessionNames(hostCall(LIST_ARGS))
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
.find(Boolean);
|
.find(Boolean);
|
||||||
@@ -334,7 +438,7 @@ function exact() {
|
|||||||
if (!first) return true;
|
if (!first) return true;
|
||||||
const answers = (t) => {
|
const answers = (t) => {
|
||||||
try {
|
try {
|
||||||
execImpl(["has-session", "-t", t]);
|
hostCall(["has-session", "-t", t]);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -379,7 +483,7 @@ function hasSession(name) {
|
|||||||
/** Our running tmux server's pid, or null when none answers. */
|
/** Our running tmux server's pid, or null when none answers. */
|
||||||
function serverPid() {
|
function serverPid() {
|
||||||
try {
|
try {
|
||||||
const pid = String(execImpl(["display-message", "-p", "#{pid}"])).trim();
|
const pid = String(hostCall(["display-message", "-p", "#{pid}"])).trim();
|
||||||
return /^\d+$/.test(pid) ? pid : null;
|
return /^\d+$/.test(pid) ? pid : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -435,10 +539,10 @@ function newSession({ name, cwd, argv, env }) {
|
|||||||
scopeImpl(args);
|
scopeImpl(args);
|
||||||
} catch {
|
} catch {
|
||||||
// No user manager (SSH without linger, no systemd): the plain start.
|
// No user manager (SSH without linger, no systemd): the plain start.
|
||||||
if (!hasSession(name)) execImpl(args);
|
if (!hasSession(name)) hostCall(args);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
execImpl(args);
|
hostCall(args);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
enableMouse(name);
|
enableMouse(name);
|
||||||
@@ -616,6 +720,53 @@ function killSession(name) {
|
|||||||
noteSession(name, accountOfQuiet(name), false);
|
noteSession(name, accountOfQuiet(name), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member's tmux server as the Users page shows it: `{running: false}`, or
|
||||||
|
* `{running: true, version, pid, startedAt, sessions}`, or `{stuck: true}`
|
||||||
|
* when it did not answer (hung, or backed off). Asked with `tmux-member`, i.e.
|
||||||
|
* with that server's own binary.
|
||||||
|
*/
|
||||||
|
function memberServerInfo(acct) {
|
||||||
|
try {
|
||||||
|
const out = String(
|
||||||
|
memberCall(acct, [
|
||||||
|
"display-message",
|
||||||
|
"-p",
|
||||||
|
"#{version}\t#{pid}\t#{start_time}\t#{server_sessions}",
|
||||||
|
])
|
||||||
|
).trim();
|
||||||
|
const [version, pid, start, sessions] = out.split("\t");
|
||||||
|
return {
|
||||||
|
running: true,
|
||||||
|
version: version || null,
|
||||||
|
pid: /^\d+$/.test(pid) ? Number(pid) : null,
|
||||||
|
startedAt: /^\d+$/.test(start) ? Number(start) * 1000 : null,
|
||||||
|
sessions: /^\d+$/.test(sessions) ? Number(sessions) : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ETMUXHUNG" || err.code === "ETIMEDOUT" || err.code === "ENOBUFS") {
|
||||||
|
return { running: true, stuck: true };
|
||||||
|
}
|
||||||
|
// "no server running" / "error connecting": nothing to report.
|
||||||
|
return { running: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a member's tmux server — every pane of theirs ends (an admin's Users
|
||||||
|
* page action, so the next start runs the installed tmux). Throws when the
|
||||||
|
* server did not go: a stuck one may need root (`sudo kill <pid>`).
|
||||||
|
*/
|
||||||
|
function killMemberServer(acct) {
|
||||||
|
unreachable.delete(acct.login); // asked deliberately, past any back-off
|
||||||
|
try {
|
||||||
|
memberCall(acct, ["kill-server"]);
|
||||||
|
} finally {
|
||||||
|
listGen++;
|
||||||
|
listed.delete(acct.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
|
/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
|
||||||
function listSessions(prefix) {
|
function listSessions(prefix) {
|
||||||
const names = [];
|
const names = [];
|
||||||
@@ -733,7 +884,7 @@ function upgradeAdvice(version = clientVersion(), platform = process.platform) {
|
|||||||
* an upgrade keeps its old version until every tmux session has closed. */
|
* an upgrade keeps its old version until every tmux session has closed. */
|
||||||
function serverVersion() {
|
function serverVersion() {
|
||||||
try {
|
try {
|
||||||
return execImpl(["display-message", "-p", "#{version}"]).trim() || null;
|
return hostCall(["display-message", "-p", "#{version}"]).trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -741,6 +892,7 @@ function serverVersion() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
__setScopeImpl,
|
__setScopeImpl,
|
||||||
|
serverExeFor,
|
||||||
session,
|
session,
|
||||||
pane,
|
pane,
|
||||||
hasSession,
|
hasSession,
|
||||||
@@ -762,6 +914,8 @@ module.exports = {
|
|||||||
upgradeAdvice,
|
upgradeAdvice,
|
||||||
serverPid,
|
serverPid,
|
||||||
memberAttach,
|
memberAttach,
|
||||||
|
memberServerInfo,
|
||||||
|
killMemberServer,
|
||||||
__setExecImpl,
|
__setExecImpl,
|
||||||
__setAccountImpl,
|
__setAccountImpl,
|
||||||
__settle,
|
__settle,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||||
|
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||||
|
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -409,6 +411,77 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/users/tmux": {
|
||||||
|
get: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "listMemberTmux",
|
||||||
|
summary:
|
||||||
|
"Each member's own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3",
|
||||||
|
},
|
||||||
|
403: err("EADMIN — a developer"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/users/{id}/tmux-restart": {
|
||||||
|
post: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "restartMemberTmux",
|
||||||
|
summary:
|
||||||
|
"End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)",
|
||||||
|
parameters: [idParam],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["currentPassword"],
|
||||||
|
properties: { currentPassword: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: "{user}" },
|
||||||
|
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
|
||||||
|
404: err("ENOUSER"),
|
||||||
|
409: err(
|
||||||
|
"ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/users/tmux-restart-idle": {
|
||||||
|
post: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "restartIdleMemberTmux",
|
||||||
|
summary:
|
||||||
|
"Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["currentPassword"],
|
||||||
|
properties: { currentPassword: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}",
|
||||||
|
},
|
||||||
|
403: err("EREAUTH / EADMIN / ELOCALMODE"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/users/{id}/avatar": {
|
"/api/users/{id}/avatar": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete —
|
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete —
|
||||||
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
||||||
* `hasToken` only, never a password), whether the account's own terminal
|
* `hasToken` only, never a password), whether the account's own terminal
|
||||||
* git uses that key (`core.sshCommand`), and the commit author (`user.name` /
|
* git uses that key (`core.sshCommand`), the SSH login name per host
|
||||||
|
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
|
||||||
* `user.email` of the account's global git config — a member's commits fail
|
* `user.email` of the account's global git config — a member's commits fail
|
||||||
* without one). The account is the signed-in user's
|
* without one). The account is the signed-in user's
|
||||||
* own Linux account under S3, else the host's, which only an admin (or local
|
* own Linux account under S3, else the host's, which only an admin (or local
|
||||||
@@ -29,6 +30,7 @@ const STATUS = {
|
|||||||
ESTOREUNREADABLE: 409,
|
ESTOREUNREADABLE: 409,
|
||||||
EBADKEYTYPE: 400,
|
EBADKEYTYPE: 400,
|
||||||
EFOREIGNSSH: 409,
|
EFOREIGNSSH: 409,
|
||||||
|
EBADSSHUSER: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
function fail(res, e) {
|
function fail(res, e) {
|
||||||
@@ -47,6 +49,7 @@ async function view(account) {
|
|||||||
ssh: keys[0] ? { ...keys[0], terminal } : null,
|
ssh: keys[0] ? { ...keys[0], terminal } : null,
|
||||||
sshKeys: keys,
|
sshKeys: keys,
|
||||||
sshTerminal: terminal ?? null,
|
sshTerminal: terminal ?? null,
|
||||||
|
sshUsers: await identity.listSshUsers(account),
|
||||||
https: identity.listHttps(account),
|
https: identity.listHttps(account),
|
||||||
author: await identity.getAuthor(account),
|
author: await identity.getAuthor(account),
|
||||||
};
|
};
|
||||||
@@ -115,6 +118,32 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SSH user name for one host (`{host, user}`) — e.g. AWS CodeCommit's SSH
|
||||||
|
* key ID, so a plain `ssh://git-codecommit…/` URL logs in. DELETE drops it.
|
||||||
|
*/
|
||||||
|
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = accountOf(req, { write: true });
|
||||||
|
await identity.setSshUser(account, req.body || {});
|
||||||
|
res.json(await view(account));
|
||||||
|
} catch (e) {
|
||||||
|
fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = accountOf(req, { write: true });
|
||||||
|
if (!(await identity.removeSshUser(account, req.params.host))) {
|
||||||
|
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
|
||||||
|
}
|
||||||
|
res.json(await view(account));
|
||||||
|
} catch (e) {
|
||||||
|
fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/https", sameOriginGuard, async (req, res) => {
|
router.post("/https", sameOriginGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const account = accountOf(req, { write: true });
|
const account = accountOf(req, { write: true });
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
||||||
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
||||||
*
|
*
|
||||||
|
* `GET /tmux` (S3) is each member's own tmux server — its version beside the
|
||||||
|
* installed one, its sessions, whether an agent of theirs works — and an
|
||||||
|
* admin restarts one (`POST /:id/tmux-restart`) or every idle outdated one
|
||||||
|
* (`POST /tmux-restart-idle`), with their password again and audited
|
||||||
|
* (server/lib/member-tmux.js); a restart ends that member's panes.
|
||||||
|
*
|
||||||
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
|
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
|
||||||
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
||||||
* owner groups — staff only like the rest of `/users`.
|
* owner groups — staff only like the rest of `/users`.
|
||||||
@@ -96,6 +102,11 @@ router.get("/audit", (req, res) => {
|
|||||||
res.json({ entries: listAudit(req.query.limit) });
|
res.json({ entries: listAudit(req.query.limit) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** S3: each member's tmux server — its version beside the installed one (member-tmux.js). */
|
||||||
|
router.get("/tmux", (_req, res) => {
|
||||||
|
res.json(require("../lib/member-tmux").overview(listUsers()));
|
||||||
|
});
|
||||||
|
|
||||||
const STATUS = {
|
const STATUS = {
|
||||||
EBADUSER: 400,
|
EBADUSER: 400,
|
||||||
EBADEMAIL: 400,
|
EBADEMAIL: 400,
|
||||||
@@ -114,6 +125,9 @@ const STATUS = {
|
|||||||
ETOOMANY: 429,
|
ETOOMANY: 429,
|
||||||
// S3: the Linux account could not be created/locked (the helper said why).
|
// S3: the Linux account could not be created/locked (the helper said why).
|
||||||
EPROVISION: 502,
|
EPROVISION: 502,
|
||||||
|
ENOS3: 409,
|
||||||
|
// A member's tmux server that would not stop: root has to kill it.
|
||||||
|
ETMUXSTUCK: 409,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,6 +304,73 @@ router.delete(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart one member's tmux server (every pane of theirs ends, so the next
|
||||||
|
* start runs the installed tmux) — the admin's password again, audited.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/tmux-restart",
|
||||||
|
accountChange(async (req) => {
|
||||||
|
const u = target(req);
|
||||||
|
const r = require("../lib/member-tmux").restart(u);
|
||||||
|
return {
|
||||||
|
user: auth.publicUser(u),
|
||||||
|
action: r.restarted
|
||||||
|
? `${u.username}: tmux restarted (was ${r.before.version || "?"}, ${r.before.sessions ?? "?"} session(s))`
|
||||||
|
: `${u.username}: tmux restart — no server was running`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart the tmux server of every member running an older tmux than the
|
||||||
|
* installed one while no agent of theirs works. One password, one audit
|
||||||
|
* entry per member restarted.
|
||||||
|
*/
|
||||||
|
router.post("/tmux-restart-idle", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!auth.isServerMode() || !req.user) {
|
||||||
|
throw Object.assign(new Error("accounts are managed in server mode only"), {
|
||||||
|
code: "ELOCALMODE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||||
|
await auth.confirmPassword(req.user, body.currentPassword);
|
||||||
|
const mt = require("../lib/member-tmux");
|
||||||
|
const all = db.prepare("SELECT * FROM users ORDER BY id").all();
|
||||||
|
const rows = mt.overview(all.map(auth.publicUser)).users;
|
||||||
|
const restarted = [];
|
||||||
|
const skipped = [];
|
||||||
|
const failed = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.outdated) continue;
|
||||||
|
if (row.busy > 0) {
|
||||||
|
skipped.push(row.username);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const u = all.find((x) => x.id === row.id);
|
||||||
|
try {
|
||||||
|
const r = mt.restart(u);
|
||||||
|
if (!r.restarted) continue;
|
||||||
|
restarted.push(row.username);
|
||||||
|
recordAccountChange(
|
||||||
|
req.user.id,
|
||||||
|
u.id,
|
||||||
|
"POST",
|
||||||
|
`${u.username}: tmux restarted (idle, was ${row.version})`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
failed.push({ username: row.username, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ restarted, skipped, failed });
|
||||||
|
} catch (err) {
|
||||||
|
const status = STATUS[err.code];
|
||||||
|
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||||
|
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/password",
|
"/:id/password",
|
||||||
accountChange(async (req, body) => {
|
accountChange(async (req, body) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user