Compare commits

...

4 Commits

Author SHA1 Message Date
nntrivi2001 d8ab850295 chore: v2.8.3 — a stuck tmux server no longer freezes the dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:46 +07:00
nntrivi2001 bac6c04a0b fix(tmux): bound calls on our own server and run them with the server's own binary
A tmux 3.8 installed under a running 3.6 server left PATH's 3.8 client
asking the 3.6 server, which spun at 100% CPU; the dashboard's
synchronous `has-session` on it had no limit and froze every page, hook
and terminal for five minutes (14:07–14:12 on the shared server).

- Every call on our own server now has a 5 s limit and the member's
  one-minute back-off (`host: tmux server not answering` meanwhile), so a
  stuck server costs the event loop one timeout per minute, not one per
  poll.
- Every call on a running server runs that server's own binary
  (`/proc/<pid>/exe`, found from its listening socket in /proc — asking
  tmux would be the very call that hangs), re-read every 10 s; PATH's
  tmux only starts a server or answers `-V`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:16 +07:00
nntrivi2001 38c7ac6635 chore: v2.8.2 — SSH key sections per git host, CodeCommit SSH key ID for a plain ssh:// clone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:53:32 +07:00
nntrivi2001 276cead826 feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work
AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a
plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x`
was refused as the Linux account name. The account can now save an SSH
user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` —
written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its
own ~/.gitconfig (as the member under S3), so the plain URL works in a
pane, in Clone and in the git bar. Only rules of exactly that shape are
listed or removed; any other insteadOf is the user's and left alone.

Settings → Git → Your git identity now shows the SSH keys one section
per kind of host, each with only its own numbered steps: GitHub · GitLab
· Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in
IAM → paste the key ID and pick the region, then a "Ready" clone line),
plus a collapsed "Other SSH hosts (advanced)". The shared key-type
picker is gone: each section has its own Generate/Replace/Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:31:30 +07:00
16 changed files with 814 additions and 125 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.1 (2026-10-15) — Stop archives / Sleep keeps a session, SSH key per type, update Auto fix._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.8.3 (2026-10-15) — host tmux calls bounded and run with the server's own binary._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -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,89 +380,191 @@ 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>
<>
{keys.map((k) => ( <KeyCard
<div key={k.type ?? "ed25519"} className="space-y-1.5"> title={t("gitIdentity.gitHubTitle")}
<textarea sub={t("gitIdentity.gitHubSub")}
readOnly type="ed25519"
aria-label={`${t("gitIdentity.publicKey")} ${k.type === "rsa" ? "RSA" : "ED25519"}`} k={keyOf("ed25519")}
value={k.publicKey} id={id}
rows={k.type === "rsa" ? 4 : 2} busy={busy}
onFocus={(e) => e.currentTarget.select()} onGenerate={generate}
className={`${INPUT} resize-none break-all font-mono text-[11px]`} onRemove={removeKey}
/> >
<div className="flex flex-wrap items-center gap-2"> <Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted"> <Step n={3}>
{k.type === "rsa" ? "RSA 4096 · " : "ED25519 · "} {t("gitIdentity.gitHubStepClone")}
{k.fingerprint} <Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.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 : (
<>
{awsRows.map((r) => (
<div key={r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.region} · {r.user}
</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>
))}
{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/&lt;repo&gt;</Code>
))}
</div>
)}
</KeyCard>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
<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> </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 && (
{terminal && ( <form
<div className="flex items-start gap-2 rounded border border-border p-2"> className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
<div className="min-w-0 flex-1"> onSubmit={(e) => {
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div> e.preventDefault();
<div className="text-[10px] text-fg-muted"> void saveOther();
{terminal === "foreign" }}
? t("gitIdentity.terminalForeign") >
: t("gitIdentity.terminalHint")} <input
</div> aria-label={t("gitIdentity.otherHost")}
</div> placeholder="git.example.com"
<Switch value={other.host}
label={t("gitIdentity.terminal")} onChange={(e) => setOther({ ...other, host: e.target.value })}
checked={terminal === "on"} className={`${INPUT} font-mono`}
disabled={busy || !id.writable || terminal === "foreign"} />
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))} <input
/> aria-label={t("gitIdentity.otherUser")}
</div> placeholder={t("gitIdentity.otherUser")}
)} autoComplete="off"
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p> value={other.user}
</> onChange={(e) => setOther({ ...other, user: e.target.value })}
) : ( className={`${INPUT} font-mono`}
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p> />
<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> </div>
{member && ( {member && (
@@ -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());
+25 -8
View File
@@ -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",
+24 -7
View File
@@ -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",
+7
View File
@@ -2676,6 +2676,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 +4215,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: {
@@ -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.3 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+2
View File
@@ -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
+11 -1
View File
@@ -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,16 @@ 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 server is still asked with `TMUX_BIN` (the one path
sudoers allows), so after a tmux upgrade restart each member's tmux server
once it is idle (`sudo kill <pid>` of `tmux -S /srv/agenthub/users/<name>/.agenthub/tmux/sock`).
- **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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.1", "version": "2.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.1", "version": "2.8.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.1", "version": "2.8.3",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+50
View File
@@ -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");
+53
View File
@@ -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 });
}
});
});
+78 -1
View File
@@ -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,
+116 -11
View File
@@ -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);
@@ -733,7 +837,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 +845,7 @@ function serverVersion() {
module.exports = { module.exports = {
__setScopeImpl, __setScopeImpl,
serverExeFor,
session, session,
pane, pane,
hasSession, hasSession,
+30 -1
View File
@@ -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 });