Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ab850295 | |||
| bac6c04a0b | |||
| 38c7ac6635 | |||
| 276cead826 | |||
| 1c5bd88747 | |||
| 0a1dcfeb7f | |||
| 58be04a4e7 | |||
| cd427d218b |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.0 (2026-10-14) — Artifacts uploads and plugin links, tab combos, account avatars._ 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
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
+6
-4
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available } = await get("/api/run/tmux");
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
console.log(
|
||||
available
|
||||
? `${c.green("✔")} tmux available to the server`
|
||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
||||
!available
|
||||
? `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
||||
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
||||
: upgrade
|
||||
? `${c.yellow("!")} tmux ${upgrade}`
|
||||
: `${c.green("✔")} tmux available to the server`
|
||||
);
|
||||
} catch {
|
||||
console.log(`${c.dim("·")} tmux not reported by this server`);
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and "Not now" close it
|
||||
* for this page load only (never while applying); "Skip this version" is the
|
||||
* one persistent, per-version dismiss.
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
* and runs the update command; the dashboard itself still decides nothing.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -81,7 +85,18 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Download,
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
SkipForward,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -89,6 +104,7 @@ import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import { startInFreeTab } from "./lanes/startSession";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
@@ -107,6 +123,32 @@ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean
|
||||
);
|
||||
}
|
||||
|
||||
/** The prompt the Auto fix session starts with: what failed, where, and the
|
||||
* limits that keep the agent from taking down the dashboard it runs under. */
|
||||
function autoFixPrompt(status: UpdateStatusPayload, error: string): string {
|
||||
const target = status.latest_release || status.remote_ref || "the newest version";
|
||||
const plugin = status.situation === "plugin_managed";
|
||||
return [
|
||||
`The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`,
|
||||
plugin
|
||||
? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)."
|
||||
: `The install is the git checkout at ${status.repo_root}.`,
|
||||
"",
|
||||
"Error:",
|
||||
"```",
|
||||
error,
|
||||
"```",
|
||||
...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []),
|
||||
"",
|
||||
"Find the cause, fix it, then finish the update" +
|
||||
(plugin
|
||||
? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)."
|
||||
: " by running the update command."),
|
||||
"Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).",
|
||||
"When done, tell me what you changed; I will restart the dashboard from its update prompt.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||
@@ -130,7 +172,7 @@ export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
|
||||
// Closed for THIS page load only (Escape, backdrop, ×) — the
|
||||
// persistent per-version dismiss is the explicit "Skip this version".
|
||||
const [closedKey, setClosedKey] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -139,6 +181,8 @@ export function UpdateNotifier() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
setStatus(s);
|
||||
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
);
|
||||
const busy = applying || restarting;
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -317,6 +361,29 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
// home for a plugin install — never a lane inside the plugin cache) and a
|
||||
// Claude Code session there with the error as its first prompt.
|
||||
const autoFix = async () => {
|
||||
if (busy || !status || !error) return;
|
||||
setFixing(true);
|
||||
try {
|
||||
const plugin = status.situation === "plugin_managed";
|
||||
const { lane } = await api.lanes.ensure({
|
||||
...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }),
|
||||
title: t("autoFixWorkspace"),
|
||||
tool: "claude",
|
||||
});
|
||||
const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) });
|
||||
if (dismissKey) setClosedKey(dismissKey);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("autoFixError"));
|
||||
} finally {
|
||||
setFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -493,16 +560,9 @@ export function UpdateNotifier() {
|
||||
title={t("skipVersionHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("skipVersion")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("later")}
|
||||
</button>
|
||||
{status.manual_command ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,11 +574,28 @@ export function UpdateNotifier() {
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
) : null}
|
||||
{/* Only after a failed update: an AI agent takes the error from here. */}
|
||||
{error ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoFix}
|
||||
disabled={busy}
|
||||
title={t("autoFixHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{fixing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Wrench className="w-3.5 h-3.5" aria-hidden />
|
||||
)}
|
||||
{t("autoFix")}
|
||||
</button>
|
||||
) : null}
|
||||
{isAutoApplicable(status.situation) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyNow}
|
||||
disabled={applying || restarting}
|
||||
disabled={busy}
|
||||
className="btn-primary disabled:opacity-70"
|
||||
>
|
||||
{applying || restarting ? (
|
||||
|
||||
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
|
||||
const statusMock = vi.fn();
|
||||
const checkMock = vi.fn();
|
||||
const applyMock = vi.fn();
|
||||
const ensureMock = vi.fn();
|
||||
const startMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lanes/startSession", () => ({
|
||||
startInFreeTab: (...args: unknown[]) => startMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
check: (...args: unknown[]) => checkMock(...args),
|
||||
apply: (...args: unknown[]) => applyMock(...args),
|
||||
},
|
||||
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -56,6 +69,9 @@ beforeEach(() => {
|
||||
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
applyMock.mockReset();
|
||||
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
|
||||
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
|
||||
navigateMock.mockReset();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
|
||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
||||
await userEvent.click(await screen.findByRole("button", { name: i18n.t("updates:later") }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
@@ -290,3 +306,26 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — Auto fix", () => {
|
||||
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
||||
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
||||
render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:updateNow"));
|
||||
expect(screen.queryByText(i18n.t("updates:later"))).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:autoFix"))).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:autoFix")));
|
||||
|
||||
await waitFor(() => expect(navigateMock).toHaveBeenCalled());
|
||||
expect(ensureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/repo", exact: true, tool: "claude" })
|
||||
);
|
||||
const [laneId, body] = startMock.mock.calls[0] ?? [];
|
||||
expect(laneId).toBe(7);
|
||||
expect(body.prompt).toContain("git merge --ff-only v2.8.0");
|
||||
expect(body.prompt).toContain(BASE_STATUS.manual_command);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/workspace?lane=7&run=agenthub-lane-7-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("WorkspaceTree", () => {
|
||||
let menu = screen.getByTestId("session-menu-s-old");
|
||||
// Ended: no pane to split or close.
|
||||
expect(within(menu).queryByText("Open in split")).toBeNull();
|
||||
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
|
||||
expect(within(menu).queryByText("Close tab (stops and archives its session)")).toBeNull();
|
||||
await user.click(within(menu).getByText("Rename"));
|
||||
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
|
||||
@@ -271,11 +271,17 @@ describe("WorkspaceTree", () => {
|
||||
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(screen.getByTestId("session-menu-sleep"));
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: false });
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
|
||||
within(screen.getByTestId("session-menu-s-live")).getByText(
|
||||
"Close tab (stops and archives its session)"
|
||||
)
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
});
|
||||
|
||||
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||
|
||||
@@ -84,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
|
||||
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
||||
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
||||
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
|
||||
|
||||
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
|
||||
function isTableStart(lines: string[], i: number): boolean {
|
||||
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
// Trim leading/trailing pipes, then split, respecting escaped pipes.
|
||||
@@ -171,7 +176,7 @@ function parseBlocks(src: string): Block[] {
|
||||
}
|
||||
|
||||
// Table: header line followed by an alignment divider
|
||||
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
|
||||
if (isTableStart(lines, i)) {
|
||||
const header = splitTableRow(line);
|
||||
const aligns = parseAlignments(lines[i + 1]!);
|
||||
i += 2;
|
||||
@@ -227,7 +232,8 @@ function parseBlocks(src: string): Block[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect until a blank line or the start of another block
|
||||
// Paragraph: collect until a blank line or the start of another block —
|
||||
// a table included, which (as in GFM) may follow prose with no blank line.
|
||||
const paraLines: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
@@ -239,7 +245,8 @@ function parseBlocks(src: string): Block[] {
|
||||
HR_RE.test(nl) ||
|
||||
UL_RE.test(nl) ||
|
||||
OL_RE.test(nl) ||
|
||||
QUOTE_RE.test(nl)
|
||||
QUOTE_RE.test(nl) ||
|
||||
isTableStart(lines, i)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
@@ -300,7 +307,10 @@ function UrlLink({ url }: { url: string }) {
|
||||
|
||||
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||
function decodeHref(href: string): string {
|
||||
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
|
||||
let p = href
|
||||
.replace(/^file:\/\//i, "")
|
||||
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
|
||||
.replace(/#.*$/, "");
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
@@ -383,13 +393,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
matchPath(codeM[1]!) === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -440,9 +444,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||
) {
|
||||
push(
|
||||
<PathLink path={decodeHref(href)}>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</PathLink>
|
||||
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
|
||||
@@ -153,4 +153,18 @@ describe("<MarkdownContent />", () => {
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a table that follows prose with no blank line (issue #83)", () => {
|
||||
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
|
||||
const { container } = render(<MarkdownContent text={text} />);
|
||||
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
|
||||
expect(container.querySelectorAll("th").length).toBe(2);
|
||||
expect(container.querySelectorAll("tbody tr").length).toBe(1);
|
||||
expect(container.textContent).not.toContain("|---|");
|
||||
});
|
||||
|
||||
it("accepts a GFM divider with single dashes", () => {
|
||||
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
|
||||
expect(container.querySelectorAll("th").length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
||||
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
|
||||
* after the same confirm for an AI tab, which may be mid-turn): **Sleep** stops
|
||||
* it and keeps the session listed to resume later, **Close tab** stops it and
|
||||
* archives the session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||
|
||||
export function SessionMenu({
|
||||
laneId,
|
||||
@@ -50,24 +51,12 @@ export function SessionMenu({
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
const closeTab = async () => {
|
||||
// Stop archives the session; Sleep keeps it listed to resume later
|
||||
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||
const endTab = (sleep: boolean) => {
|
||||
if (!run) return;
|
||||
onClose();
|
||||
if (
|
||||
run.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -105,8 +94,13 @@ export function SessionMenu({
|
||||
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
||||
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
|
||||
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
|
||||
{canSleep(run) && (
|
||||
<MenuItem testId="session-menu-sleep" onSelect={() => endTab(true)}>
|
||||
{t("tabs.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{run && (
|
||||
<MenuItem danger onSelect={() => void closeTab()}>
|
||||
<MenuItem danger onSelect={() => endTab(false)}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
* prompt it started on, else its tool and number; a combo tab shows every
|
||||
* pane's icon and name. A tab carries a state dot (waiting for you / error /
|
||||
* running — a combo shows the most urgent, with screen-reader text). A tab's
|
||||
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
|
||||
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
|
||||
* X stops every session in it AND archives them (`endRuns`,
|
||||
* `DELETE /api/run/:id?archive=1`); the moon beside it (and the tab menu's
|
||||
* Sleep) stops its AI sessions but keeps them listed, to resume later. Either
|
||||
* asks first when one is an AI tool, since it may be mid-turn; a shell closes
|
||||
* at once. Arrow
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
@@ -48,13 +51,13 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { House, Moon, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -432,30 +435,10 @@ export function ConsoleTabs({
|
||||
}
|
||||
};
|
||||
|
||||
// An AI session may be mid-turn: stopping a tab holding one asks first. A
|
||||
// shell holds no agent.
|
||||
const close = async (tab: Tab) => {
|
||||
if (
|
||||
tab.runs.some((r) => r.tool !== "shell") &&
|
||||
!(await confirmDialog({
|
||||
title:
|
||||
tab.runs.length > 1
|
||||
? t("tabs.stopGroupTitle", { count: tab.runs.length })
|
||||
: t("tabs.stopTitle", { name: label(tab.focus) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
for (const r of tab.runs) {
|
||||
try {
|
||||
await api.run.kill(r.id);
|
||||
onClosed(r.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
// Stop archives the tab's sessions; Sleep keeps them listed to resume
|
||||
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||
const close = async (tab: Tab, sleep = false) => {
|
||||
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
|
||||
};
|
||||
|
||||
const startRename = (r: RunHandle) => {
|
||||
@@ -638,6 +621,20 @@ export function ConsoleTabs({
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{state && <RunDot state={state} />}
|
||||
</button>
|
||||
{tab.runs.some(canSleep) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-sleep-${tab.key}`}
|
||||
onClick={() => void close(tab, true)}
|
||||
aria-label={t("tabs.sleep")}
|
||||
title={t("tabs.sleep")}
|
||||
className={`shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-close-${tab.key}`}
|
||||
@@ -785,6 +782,14 @@ export function ConsoleTabs({
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{ctxTab.runs.some(canSleep) && (
|
||||
<MenuItem
|
||||
testId="console-tab-menu-sleep"
|
||||
onSelect={pickTab((tab) => void close(tab, true))}
|
||||
>
|
||||
{t("tabs.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
||||
* session there (X, `onStop`) without taking it out first. The grid shows
|
||||
* session there (X, `onStop`, which archives it) or put an AI session to
|
||||
* sleep (the moon left of it, `onSleep`: stopped but kept to resume) without
|
||||
* taking it out first. The grid shows
|
||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||
* shows its state dot, as its tab does.
|
||||
@@ -40,11 +42,13 @@ import {
|
||||
GripVertical,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Moon,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { canSleep } from "../../lib/stopSession";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
EMPTY_GROUPS,
|
||||
@@ -402,6 +406,7 @@ export function PaneGrid({
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
onSleep,
|
||||
keyOf = (slot) => slot,
|
||||
kept = [],
|
||||
}: {
|
||||
@@ -410,8 +415,10 @@ export function PaneGrid({
|
||||
keyOf?: (slot: number) => number;
|
||||
/** Panes kept mounted, always hidden — never in the layout. */
|
||||
kept?: { key: number; node: ReactNode }[];
|
||||
/** Stops the session in `slot` (the header's X). */
|
||||
/** Stops (and archives) the session in `slot` (the header's X). */
|
||||
onStop?: (slot: number) => void;
|
||||
/** Sleeps the AI session in `slot`: stopped, kept to resume (the moon). */
|
||||
onSleep?: (slot: number) => void;
|
||||
/** A live AI run's state for its header dot (none for a shell). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
@@ -641,6 +648,18 @@ export function PaneGrid({
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{run && onSleep && canSleep(run) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-sleep-${slot}`}
|
||||
title={t("panes.sleep")}
|
||||
aria-label={t("panes.sleep")}
|
||||
onClick={() => onSleep(slot)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{run && onStop && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -696,6 +715,11 @@ export function PaneGrid({
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
|
||||
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
|
||||
{t("panes.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onStop && runOf(paneCtx.menu.item) && (
|
||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||
{t("panes.stop")}
|
||||
|
||||
@@ -355,9 +355,20 @@ export const TerminalView = memo(function TerminalView({
|
||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||
// because that protocol reports button presses only, never the wheel — so
|
||||
// xterm takes the emulation path for it as well.
|
||||
term.attachCustomWheelEventHandler(() => {
|
||||
// psmux (Windows) never turns tracking on, so xterm never reports the
|
||||
// wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
|
||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||
let ownWheel = false;
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
if (!tracked && ownWheel) {
|
||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return tracked || term.buffer.active.type !== "alternate";
|
||||
});
|
||||
|
||||
@@ -530,6 +541,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
requestAnimationFrame(scaleToFit);
|
||||
} else if (msg.type === "lead") {
|
||||
takeLead();
|
||||
} else if (msg.type === "wheel") {
|
||||
ownWheel = true;
|
||||
} else if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
|
||||
@@ -231,6 +231,24 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
|
||||
// psmux (Windows) never turns mouse tracking on for its client, so xterm
|
||||
// never reports the wheel and the pane could not be scrolled at all.
|
||||
termState.modes.mouseTrackingMode = "none";
|
||||
termState.buffer.active.type = "alternate";
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||
ws.onmessage?.({ data: '{"type":"wheel"}' });
|
||||
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||
expect(ws.sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* key a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it — and, for a user with their own Linux account (S3), their own HTTPS
|
||||
* 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
|
||||
* it, and whether git typed in the account's own terminal uses it too (a
|
||||
* 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
|
||||
* 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
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @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 { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
@@ -21,12 +25,147 @@ import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -65,23 +204,24 @@ export function GitIdentitySettings() {
|
||||
};
|
||||
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
|
||||
const generate = async () => {
|
||||
// An account may hold one of each; an older server answers `ssh` only.
|
||||
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
|
||||
const generate = async (type: "ed25519" | "rsa") => {
|
||||
const taken = !!keyOf(type);
|
||||
if (
|
||||
id?.ssh &&
|
||||
taken &&
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
|
||||
};
|
||||
|
||||
const removeKey = async () => {
|
||||
const removeKey = async (type: "ed25519" | "rsa") => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
@@ -89,7 +229,7 @@ export function GitIdentitySettings() {
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKey());
|
||||
await run(() => api.gitIdentity.removeKey(type));
|
||||
};
|
||||
|
||||
const saveLogin = async () => {
|
||||
@@ -109,6 +249,44 @@ export function GitIdentitySettings() {
|
||||
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) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
@@ -151,7 +329,7 @@ export function GitIdentitySettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="max-w-2xl text-xs text-fg-muted">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
|
||||
</p>
|
||||
|
||||
@@ -162,7 +340,7 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
|
||||
<form
|
||||
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAuthor();
|
||||
@@ -202,75 +380,195 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="max-w-2xl 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">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.sshTitle")}
|
||||
</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()}>
|
||||
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{id.ssh ? (
|
||||
<>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={t("gitIdentity.publicKey")}
|
||||
value={id.ssh.publicKey}
|
||||
rows={3}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
||||
<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 : (
|
||||
<>
|
||||
{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/<repo></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 className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{id.ssh.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
|
||||
{id.ssh.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
</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>
|
||||
{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 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void removeKey()}
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveOther();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
<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>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
|
||||
</div>
|
||||
|
||||
{member && (
|
||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.httpsTitle")}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* @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
|
||||
* 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>
|
||||
*/
|
||||
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 { GitIdentitySettings } from "../GitIdentitySettings";
|
||||
import { api, type GitIdentity } from "../../../lib/api";
|
||||
@@ -19,6 +21,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
setTerminalSsh: vi.fn(),
|
||||
setSshUser: vi.fn(),
|
||||
removeSshUser: vi.fn(),
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
@@ -48,10 +53,11 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
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(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
||||
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
||||
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
|
||||
});
|
||||
|
||||
it("copies the public key and asks before replacing it", async () => {
|
||||
@@ -85,16 +91,82 @@ describe("GitIdentitySettings", () => {
|
||||
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.generateKey).mockResolvedValue(
|
||||
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps each key in its own section and deletes each on its own", async () => {
|
||||
const RSA = {
|
||||
type: "rsa" as const,
|
||||
publicKey: "ssh-rsa AAAAB3 an@agenthub",
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const ED = { ...KEY, type: "ed25519" as const };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
|
||||
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||
// Each section explains only its own host.
|
||||
expect(within(gh).queryByText(/IAM/)).toBeNull();
|
||||
expect(within(aws).queryByText(/GitHub/)).toBeNull();
|
||||
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
|
||||
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 () => {
|
||||
@@ -118,7 +190,7 @@ describe("GitIdentitySettings", () => {
|
||||
view({ account: "host", writable: false, ssh: KEY })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText(/^Public key/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"tabs.new": "New tab",
|
||||
"tabs.newTerminal": "New terminal",
|
||||
"tabs.terminal": "Terminal",
|
||||
"tabs.close": "Close tab (stops its session)",
|
||||
"tabs.close": "Close tab (stops and archives its session)",
|
||||
"tabs.full": "All 10 sessions are in use — stop one first",
|
||||
"tabs.agentSettings": "Agent settings…",
|
||||
"tabs.settings": "Settings",
|
||||
@@ -465,7 +465,8 @@
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
"panes.closeShort": "Move to own tab",
|
||||
"panes.stop": "Stop session",
|
||||
"panes.stop": "Stop and archive session",
|
||||
"panes.sleep": "Sleep session (stop, keep to resume later)",
|
||||
"changes.copyPath": "Copy relative path",
|
||||
"changes.viewCommit": "View commit",
|
||||
"changes.copySha": "Copy commit SHA",
|
||||
@@ -479,8 +480,13 @@
|
||||
"tabs.renameHint": "Double-click to rename",
|
||||
"tabs.stopTitle": "Stop “{{name}}”?",
|
||||
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?",
|
||||
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
|
||||
"tabs.stopBody": "Stopping ends the session, even mid-turn, and archives it: it leaves this workspace's list (Unarchive brings it back). To keep it for later, use Sleep instead.",
|
||||
"tabs.stopConfirm": "Stop session",
|
||||
"tabs.sleep": "Sleep (stop, keep to resume later)",
|
||||
"tabs.sleepTitle": "Sleep “{{name}}”?",
|
||||
"tabs.sleepGroupTitle": "Sleep the {{count}} sessions in this tab?",
|
||||
"tabs.sleepBody": "Sleep stops the session, even mid-turn, but keeps it in this workspace's list. Open it and send a message to resume it.",
|
||||
"tabs.sleepConfirm": "Sleep",
|
||||
"changes.loadMore": "Load more commits",
|
||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||
"changes.splitter": "Resize the changes panel",
|
||||
@@ -617,5 +623,6 @@
|
||||
"fx.readOnlyHome": "Another user's home — view and download only",
|
||||
"fx.readOnly": "Read-only",
|
||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left."
|
||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
||||
}
|
||||
|
||||
@@ -901,8 +901,8 @@
|
||||
"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.",
|
||||
"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",
|
||||
"generate": "Generate SSH key",
|
||||
"sshTitle": "SSH keys",
|
||||
"generate": "Generate 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.",
|
||||
"keyMade": "SSH key ready — add its public key to your git host.",
|
||||
@@ -910,14 +910,34 @@
|
||||
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
||||
"publicKey": "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). Paste the whole line as one line. The private key never leaves the server.",
|
||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
||||
"noKey": "No key yet. Generate one to start.",
|
||||
"httpsTitle": "HTTPS logins",
|
||||
"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.",
|
||||
"keyType": "Key type",
|
||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
||||
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)"
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -31,5 +31,9 @@
|
||||
"installedTitle": "Version {{version}} is installed but not running",
|
||||
"later": "Not now",
|
||||
"skipVersion": "Skip this version",
|
||||
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered."
|
||||
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered.",
|
||||
"autoFix": "Auto fix",
|
||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||
"autoFixWorkspace": "AgentHub update",
|
||||
"autoFixError": "Could not start the Auto fix session"
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"tabs.new": "Tab mới",
|
||||
"tabs.newTerminal": "Terminal mới",
|
||||
"tabs.terminal": "Terminal",
|
||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
||||
"tabs.close": "Đóng tab (dừng và lưu trữ phiên của nó)",
|
||||
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
||||
"tabs.agentSettings": "Cài đặt agent…",
|
||||
"tabs.settings": "Cài đặt",
|
||||
@@ -465,7 +465,8 @@
|
||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||
"panes.closeShort": "Tách ra tab riêng",
|
||||
"panes.stop": "Dừng phiên",
|
||||
"panes.stop": "Dừng và lưu trữ phiên",
|
||||
"panes.sleep": "Cho phiên ngủ (dừng, giữ lại để resume sau)",
|
||||
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||
"changes.viewCommit": "Xem commit",
|
||||
"changes.copySha": "Sao chép SHA của commit",
|
||||
@@ -479,8 +480,13 @@
|
||||
"tabs.renameHint": "Nhấp đúp để đổi tên",
|
||||
"tabs.stopTitle": "Dừng “{{name}}”?",
|
||||
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?",
|
||||
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
|
||||
"tabs.stopBody": "Dừng sẽ kết thúc phiên, kể cả khi đang chạy dở, và lưu trữ nó: phiên rời khỏi danh sách của workspace (bấm Bỏ lưu trữ để đưa lại). Muốn giữ lại để dùng tiếp thì hãy chọn Ngủ.",
|
||||
"tabs.stopConfirm": "Dừng phiên",
|
||||
"tabs.sleep": "Ngủ (dừng, giữ lại để resume sau)",
|
||||
"tabs.sleepTitle": "Cho “{{name}}” ngủ?",
|
||||
"tabs.sleepGroupTitle": "Cho {{count}} session trong tab này ngủ?",
|
||||
"tabs.sleepBody": "Ngủ sẽ dừng phiên, kể cả khi đang chạy dở, nhưng vẫn giữ nó trong danh sách của workspace. Mở phiên và gửi tin nhắn để resume.",
|
||||
"tabs.sleepConfirm": "Ngủ",
|
||||
"changes.loadMore": "Tải thêm commit",
|
||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||
@@ -617,5 +623,6 @@
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
|
||||
"fx.readOnly": "Chỉ đọc",
|
||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái."
|
||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"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",
|
||||
"generate": "Tạo SSH key",
|
||||
"generate": "Tạo 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.",
|
||||
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
||||
@@ -910,14 +910,34 @@
|
||||
"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",
|
||||
"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). 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ó SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
||||
"noKey": "Chưa có key. Bấm Tạo key để bắt đầu.",
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -31,5 +31,9 @@
|
||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
|
||||
"later": "Để sau",
|
||||
"skipVersion": "Bỏ qua phiên bản này",
|
||||
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo."
|
||||
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo.",
|
||||
"autoFix": "Tự sửa",
|
||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||
"autoFixError": "Không mở được phiên Tự sửa"
|
||||
}
|
||||
|
||||
+37
-7
@@ -2670,7 +2670,17 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace, type }),
|
||||
}),
|
||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||
/** DELETE /api/me/git/ssh-key — the key of `type`, or every key. */
|
||||
removeKey: (type?: "ed25519" | "rsa") =>
|
||||
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
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 }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -2936,8 +2946,12 @@ export const api = {
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
|
||||
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
||||
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
|
||||
* version, and this platform's upgrade advice when it is too old. */
|
||||
tmuxAvailable: () =>
|
||||
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
|
||||
"/run/tmux"
|
||||
),
|
||||
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
||||
@@ -2988,9 +3002,13 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
/** DELETE /api/run/:id - kill the tmux session. */
|
||||
kill: (id: string) =>
|
||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** DELETE /api/run/:id - kill the tmux session. `archive` (the UI's Stop)
|
||||
* also archives the session it held; Sleep leaves it listed to resume. */
|
||||
kill: (id: string, opts?: { archive?: boolean }) =>
|
||||
request<{ ok: true; archived?: string | null }>(
|
||||
`/run/${encodeURIComponent(id)}${opts?.archive ? "?archive=1" : ""}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
|
||||
* killed tmux, crash), newest first; the caller's own workspaces only. */
|
||||
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
|
||||
@@ -4186,7 +4204,19 @@ export interface GitIdentity {
|
||||
account: "member" | "host";
|
||||
/** False for the host's identity seen by a non-admin. */
|
||||
writable: boolean;
|
||||
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
|
||||
ssh: {
|
||||
type?: "ed25519" | "rsa";
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
|
||||
terminal?: "on" | "off" | "foreign";
|
||||
} | null;
|
||||
/** Every key the account holds (ED25519 first); absent from an older server. */
|
||||
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
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. */
|
||||
author: { name: string | null; email: string | null };
|
||||
https: {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file stopSession — the one way the UI ends live panes: **Stop** kills the
|
||||
* pane and ARCHIVES the session it held (`DELETE /api/run/:id?archive=1`), so
|
||||
* a session the user closed leaves its workspace's lists; **Sleep** kills the
|
||||
* pane and KEEPS the session listed, to be resumed later by sending it a
|
||||
* message. Both ask first when any pane holds an AI agent (it may be
|
||||
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
||||
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
||||
* session's right-click menu (`SessionMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type RunHandle } from "./api";
|
||||
import { confirmDialog } from "./confirm";
|
||||
import { toast } from "./toast";
|
||||
|
||||
/** Whether a pane can sleep: an AI pane (a shell has no session to keep). */
|
||||
export const canSleep = (run: RunHandle | null | undefined): boolean =>
|
||||
!!run && run.tool !== "shell";
|
||||
|
||||
/**
|
||||
* Stop (archive) or sleep (keep) `runs` after a confirm, and return the ids
|
||||
* that were ended. `name` titles a one-pane confirm; `t` is the `lanes`
|
||||
* namespace.
|
||||
*/
|
||||
export async function endRuns(
|
||||
runs: RunHandle[],
|
||||
{ sleep = false, name, t }: { sleep?: boolean; name: string; t: TFunction }
|
||||
): Promise<string[]> {
|
||||
const list = sleep ? runs.filter(canSleep) : runs;
|
||||
if (!list.length) return [];
|
||||
const kind = sleep ? "sleep" : "stop";
|
||||
if (
|
||||
list.some((r) => r.tool !== "shell") &&
|
||||
!(await confirmDialog({
|
||||
title:
|
||||
list.length > 1
|
||||
? t(`tabs.${kind}GroupTitle`, { count: list.length })
|
||||
: t(`tabs.${kind}Title`, { name }),
|
||||
message: t(`tabs.${kind}Body`),
|
||||
confirmLabel: t(`tabs.${kind}Confirm`),
|
||||
destructive: !sleep,
|
||||
}))
|
||||
)
|
||||
return [];
|
||||
const ended: string[] = [];
|
||||
for (const r of list) {
|
||||
try {
|
||||
await api.run.kill(r.id, { archive: !sleep });
|
||||
ended.push(r.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
@@ -81,8 +81,9 @@ import type {
|
||||
} from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { endRuns } from "../lib/stopSession";
|
||||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
@@ -423,27 +424,12 @@ function ConsoleArea({
|
||||
else onNewSession?.();
|
||||
};
|
||||
|
||||
// A pane header's X: stops that one session, as a one-pane tab's X does —
|
||||
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
|
||||
const stopPane = async (slot: number) => {
|
||||
// A pane header's X stops (and archives) that one session, its moon sleeps
|
||||
// it — as a one-pane tab's buttons do (`endRuns` asks first for an AI pane).
|
||||
const stopPane = async (slot: number, sleep = false) => {
|
||||
const run = runOf(slot);
|
||||
if (!run) return;
|
||||
if (
|
||||
run.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: label(run) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
ended(slot);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
if ((await endRuns([run], { sleep, name: label(run), t })).length) ended(slot);
|
||||
};
|
||||
|
||||
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
|
||||
@@ -632,6 +618,7 @@ function ConsoleArea({
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
onSleep={(slot) => void stopPane(slot, true)}
|
||||
// Keyed by workspace too, so a kept pane coming back is the same one.
|
||||
keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)}
|
||||
kept={kept}
|
||||
@@ -665,6 +652,25 @@ export function Workspace() {
|
||||
const { local } = usePermissions();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
|
||||
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
|
||||
// Sticky (an error toast) so the command can be copied. An older server or
|
||||
// no answer says nothing.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const { upgrade } = await api.run.tmuxAvailable();
|
||||
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
|
||||
} catch {
|
||||
/* nothing to say */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [tLanes]);
|
||||
|
||||
// Lane state: the list is the shared store the sidebar reads too; the
|
||||
// selection is the URL's `?lane=` (the sidebar sets it, so does a
|
||||
// notification click), falling back to the last one shown, then the first.
|
||||
|
||||
@@ -459,6 +459,23 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("asks the user to upgrade a tmux older than their platform needs", async () => {
|
||||
const { toast } = await import("../../lib/toast");
|
||||
const shown = vi.spyOn(toast, "error");
|
||||
const run = api.run as unknown as Record<string, unknown>;
|
||||
run.tmuxAvailable = vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
version: "3.3.2",
|
||||
upgrade: "psmux 3.3.2 is older than 3.4.0 — winget upgrade marlocarlo.psmux",
|
||||
});
|
||||
onTestFinished(() => {
|
||||
delete run.tmuxAvailable;
|
||||
shown.mockRestore();
|
||||
});
|
||||
await renderWorkspace();
|
||||
expect(shown).toHaveBeenCalledWith(expect.stringContaining("winget upgrade marlocarlo.psmux"));
|
||||
});
|
||||
|
||||
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
|
||||
lanesToReturn = [];
|
||||
await renderWorkspace();
|
||||
@@ -749,11 +766,21 @@ describe("console tabs", () => {
|
||||
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tab's moon sleeps its session: stopped but not archived", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-sleep-0"));
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: false });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
|
||||
);
|
||||
});
|
||||
|
||||
it("closing the active tab stops its session and opens the next one", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
|
||||
);
|
||||
@@ -899,8 +926,8 @@ describe("split panes", () => {
|
||||
withRuns([live(1)]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
expect(shownPanes()).toEqual(["pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
});
|
||||
@@ -915,7 +942,7 @@ describe("split panes", () => {
|
||||
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledTimes(1);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1", { archive: true });
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
|
||||
expect(screen.queryByTestId("console-tab-1")).toBeNull();
|
||||
});
|
||||
@@ -1000,9 +1027,11 @@ describe("split panes", () => {
|
||||
|
||||
await right(screen.getByTestId("console-tab-2"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("console-tab-menu-2")).getByText("Close tab (stops its session)")
|
||||
within(screen.getByTestId("console-tab-menu-2")).getByText(
|
||||
"Close tab (stops and archives its session)"
|
||||
)
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
});
|
||||
|
||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.0 (rebuild client to match)
|
||||
UI build v2.8.3 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+9
-6
@@ -1042,9 +1042,12 @@ cannot collide on `index.lock`.
|
||||
#### Your git identity
|
||||
|
||||
```
|
||||
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
|
||||
POST /api/me/git/ssh-key Make the SSH key — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS without replace) → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND)
|
||||
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ type, publicKey, fingerprint }] (ed25519 first), sshTerminal, https: [{ protocol, host, username, hasToken }] }
|
||||
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`
|
||||
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 }
|
||||
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
|
||||
@@ -2792,7 +2795,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
|
||||
|
||||
```http
|
||||
GET /api/run List all live runs (computed fresh from tmux state)
|
||||
GET /api/run/tmux { available: bool } — whether tmux is on PATH
|
||||
GET /api/run/tmux { available: bool, version, upgrade } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; the Workspace shows it as a sticky toast
|
||||
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
||||
@@ -2807,7 +2810,7 @@ POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
DELETE /api/run/:id[?archive=1] Kill the pane → {ok}. `archive=1` (the UI's Stop) also archives the session it held → {ok, archived: <session id> | null}; without it (Sleep, the CLI) the session stays listed to resume
|
||||
```
|
||||
|
||||
`:id` must be a workspace pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
|
||||
@@ -3209,7 +3212,7 @@ Sent when a notification is created.
|
||||
|
||||
#### /ws-pty/:runId — PTY frames
|
||||
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
|
||||
#### cc_config_changed
|
||||
|
||||
|
||||
+1
-1
@@ -288,7 +288,7 @@ Because a workspace's services are fully detached, restarting the dashboard (or
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile), remote-source status |
|
||||
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile) and, when it is older than the platform needs, how to upgrade it, remote-source status |
|
||||
| `agenthub info` | The raw `/api/settings/info` JSON (pipe it to `jq`) |
|
||||
| `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` |
|
||||
| `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
+11
-1
@@ -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.
|
||||
- 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.
|
||||
- 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), an SSH key AgentHub makes for them 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 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:
|
||||
- the workspace prompt and its context files;
|
||||
- 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
|
||||
3 s. Every other synchronous call
|
||||
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
|
||||
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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.0",
|
||||
"version": "2.8.3",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+11
-22
@@ -247,15 +247,12 @@ function systemdStatus() {
|
||||
* service can still see a different one — `GET /api/run/tmux` is the server's
|
||||
* own answer, and `agenthub doctor` reports that.
|
||||
*
|
||||
* Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns
|
||||
* them on), and when a new binary still talks to an older running server — a
|
||||
* server keeps its version until every tmux session has closed. The doctor
|
||||
* only says so: it never installs or restarts tmux.
|
||||
* Also WARN below the platform's minimum (`tmux.upgradeAdvice`: psmux 3.4.0
|
||||
* on Windows, tmux 3.7 elsewhere — the same advice the Workspace shows), and
|
||||
* when a new binary still talks to an older running server — a server keeps
|
||||
* its version until every tmux session has closed. The doctor only says so:
|
||||
* it never installs or restarts tmux.
|
||||
*/
|
||||
const hasScrollbars = (version) => {
|
||||
const m = /(\d+)\.(\d+)/.exec(version || "");
|
||||
return !!m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 7));
|
||||
};
|
||||
|
||||
function tmuxStatus() {
|
||||
const tmux = require("../server/lib/tmux");
|
||||
@@ -268,26 +265,18 @@ function tmuxStatus() {
|
||||
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
|
||||
];
|
||||
}
|
||||
if (!hasScrollbars(client)) {
|
||||
return [
|
||||
"warn",
|
||||
"tmux",
|
||||
`${client} works, but terminal panes get a scrollbar only from tmux 3.7. ` +
|
||||
"Upgrade: brew upgrade tmux (macOS); on Linux, when the distro ships an older one " +
|
||||
"(Ubuntu 24.04 has 3.4), build the release from https://github.com/tmux/tmux/releases " +
|
||||
"(needs libevent-dev libncurses-dev bison: ./configure && make && sudo make install)",
|
||||
];
|
||||
}
|
||||
const advice = tmux.upgradeAdvice(client);
|
||||
if (advice) return ["warn", "tmux", `${client} works, but upgrade it: ${advice}`];
|
||||
const server = tmux.serverVersion();
|
||||
if (server && !hasScrollbars(server)) {
|
||||
if (server && tmux.upgradeAdvice(server)) {
|
||||
return [
|
||||
"warn",
|
||||
"tmux",
|
||||
`${client} installed, but the running tmux server is ${server} — panes get their ` +
|
||||
"scrollbar once every tmux session has closed (or after a reboot)",
|
||||
`${client} installed, but the running tmux server is ${server} — it is used ` +
|
||||
"until every tmux session has closed (or after a reboot)",
|
||||
];
|
||||
}
|
||||
return ["ok", "tmux", `${client} on PATH — the run console can start sessions, with scrollbars`];
|
||||
return ["ok", "tmux", `${client} on PATH — the run console can start sessions`];
|
||||
}
|
||||
|
||||
function countLegacyHookEntries() {
|
||||
|
||||
@@ -68,6 +68,23 @@ const env = {
|
||||
};
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
// psmux (the WinGet `tmux` on Windows) ignores TMUX_TMPDIR — and 3.3.x its
|
||||
// PSMUX_DATA_DIR too — so every tmux call reached the user's REAL server:
|
||||
// quit.test.js's Quit killed every live lane pane, and the kill-server below
|
||||
// the rest. Only `-L` isolates there, which no env var can add to each call,
|
||||
// so on Windows the suites get no tmux at all; theirs skip (or fail) instead.
|
||||
if (process.platform === "win32") {
|
||||
const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
|
||||
if (key) {
|
||||
env[key] = env[key]
|
||||
.split(path.delimiter)
|
||||
.filter(
|
||||
(d) => !["tmux.exe", "tmux.cmd", "tmux.bat"].some((f) => fs.existsSync(path.join(d, f)))
|
||||
)
|
||||
.join(path.delimiter);
|
||||
}
|
||||
delete env.PSMUX_SESSION;
|
||||
}
|
||||
// Git exports these to a hook it runs from a linked worktree (the pre-commit
|
||||
// hook runs this suite): inherited, every `git init`/`commit` a test makes in
|
||||
// its temp folder went to the REAL repository instead — commits on the
|
||||
|
||||
@@ -267,16 +267,34 @@ describe("/api/me/git", () => {
|
||||
assert.equal(del.body.ssh, null);
|
||||
});
|
||||
|
||||
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
|
||||
await request("POST", "/api/me/git/ssh-key", {});
|
||||
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
|
||||
it("keeps an RSA 4096 key (AWS CodeCommit) BESIDE the ed25519 one", async () => {
|
||||
const ed = await request("POST", "/api/me/git/ssh-key", {});
|
||||
const rsa = await request("POST", "/api/me/git/ssh-key", { type: "rsa" });
|
||||
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
|
||||
assert.equal(rsa.body.ssh.type, "rsa");
|
||||
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
||||
assert.deepEqual(
|
||||
rsa.body.sshKeys.map((k) => k.type),
|
||||
["ed25519", "rsa"]
|
||||
);
|
||||
// `ssh` stays the first key, as for a client that knows only one.
|
||||
assert.equal(rsa.body.ssh.publicKey, ed.body.ssh.publicKey);
|
||||
assert.match(rsa.body.sshKeys[1].publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
||||
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
|
||||
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
||||
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
|
||||
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
|
||||
assert.ok(fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was kept");
|
||||
// Each type is replaced and deleted on its own.
|
||||
assert.equal((await request("POST", "/api/me/git/ssh-key", { type: "rsa" })).status, 409);
|
||||
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", replace: true });
|
||||
assert.notEqual(again.body.sshKeys[1].publicKey, rsa.body.sshKeys[1].publicKey);
|
||||
assert.equal(again.body.sshKeys[0].publicKey, ed.body.ssh.publicKey);
|
||||
const noEd = await request("DELETE", "/api/me/git/ssh-key?type=ed25519");
|
||||
assert.deepEqual(
|
||||
noEd.body.sshKeys.map((k) => k.type),
|
||||
["rsa"]
|
||||
);
|
||||
assert.equal(noEd.body.ssh.type, "rsa");
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=ed25519")).status, 404);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=dsa")).status, 400);
|
||||
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -317,6 +335,114 @@ describe("/api/me/git", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/me/git/ssh-terminal (issue #82)", () => {
|
||||
const gitGet = () => {
|
||||
try {
|
||||
return execFileSync("git", ["config", "--global", "--get", "core.sshCommand"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
it("points the account's terminal git at the key, keeping the ~/.ssh defaults", async () => {
|
||||
const made = await request("POST", "/api/me/git/ssh-key", { replace: true });
|
||||
assert.equal(made.status, 201);
|
||||
// The host's terminal is an admin's switch: never turned on by itself.
|
||||
assert.equal(made.body.ssh.terminal, "off");
|
||||
const on = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
|
||||
assert.equal(on.status, 200);
|
||||
assert.equal(on.body.ssh.terminal, "on");
|
||||
const cmd = gitGet();
|
||||
assert.match(
|
||||
cmd,
|
||||
/^ssh -o IdentityFile='.*agenthub-ssh\/id_ed25519' -o IdentityFile='.*id_rsa'/
|
||||
);
|
||||
assert.match(cmd, /-o IdentityFile=~\/\.ssh\/id_ed25519/);
|
||||
// ssh itself reads it: ours first, then the defaults.
|
||||
const ids = execFileSync(
|
||||
"sh",
|
||||
["-c", `${cmd.replace(/^ssh /, "ssh -G -F /dev/null ")} x.test`],
|
||||
{
|
||||
encoding: "utf8",
|
||||
}
|
||||
)
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("identityfile "));
|
||||
assert.match(ids[0], /agenthub-ssh\/id_ed25519$/);
|
||||
assert.ok(ids.some((l) => l.endsWith("/.ssh/id_ed25519")));
|
||||
// The dashboard's own fetch still gets its BatchMode command over ours.
|
||||
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
|
||||
assert.equal(off.body.ssh.terminal, "off");
|
||||
assert.equal(gitGet(), "");
|
||||
});
|
||||
|
||||
it("never overwrites a core.sshCommand the user wrote", async () => {
|
||||
execFileSync("git", ["config", "--global", "core.sshCommand", "ssh -i ~/mine"]);
|
||||
try {
|
||||
const r = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "EFOREIGNSSH");
|
||||
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
|
||||
assert.equal(off.body.ssh.terminal, "foreign");
|
||||
assert.equal(gitGet(), "ssh -i ~/mine");
|
||||
} finally {
|
||||
execFileSync("git", ["config", "--global", "--unset", "core.sshCommand"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
@@ -356,7 +482,12 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
it("keeps the key and the logins in the member's own home", async () => {
|
||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||
assert.equal(account.kind, "member");
|
||||
calls = [];
|
||||
await identity.generateSshKey(account);
|
||||
// A member's terminal git uses the key from the start (issue #82).
|
||||
const set = calls.find((c) => c.args[2] === "core.sshCommand");
|
||||
assert.equal(set.cwd, HOME);
|
||||
assert.match(set.args[3], /IdentityFile='.*\/users\/an\/\.config\/agenthub\/ssh\/id_ed25519'/);
|
||||
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
|
||||
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
||||
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
|
||||
@@ -381,7 +512,11 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
args[i - 1],
|
||||
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/
|
||||
);
|
||||
assert.match(args[i + 2], /^core\.sshCommand=ssh -i /);
|
||||
// Persisted by file name, both types: a key made later is offered too.
|
||||
assert.match(
|
||||
args[i + 2],
|
||||
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
|
||||
);
|
||||
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* an install its browser run console cannot work, because every run the
|
||||
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
|
||||
* and never FAIL: sessions, analytics, lanes and the CLI all work without the
|
||||
* binary, so failing a doctor run over it would cry wolf. A tmux older than 3.7
|
||||
* (no pane scrollbars) also warns, as does a new binary still talking to an
|
||||
* older running server. tmux is faked through the module's own exec seam, so
|
||||
* binary, so failing a doctor run over it would cry wolf. A tmux older than its
|
||||
* platform asks for (tmux.upgradeAdvice: psmux 3.4.0 on Windows, tmux 3.7
|
||||
* elsewhere) also warns, as does a new binary still talking to an older
|
||||
* running server. tmux is faked through the module's own exec seam, so
|
||||
* the test never depends on the host having it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -56,18 +57,18 @@ describe("plugin-doctor: tmux row", () => {
|
||||
assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok");
|
||||
});
|
||||
|
||||
it("warns, with the upgrade path, when tmux is older than 3.7", () => {
|
||||
const row = rowWithTmux("3.4", "3.4");
|
||||
it("warns, with this platform's upgrade path, when tmux is older than it asks for", () => {
|
||||
// 2.9 is too old everywhere; the advice (minimum, command) is per platform.
|
||||
const row = rowWithTmux("2.9", "2.9");
|
||||
|
||||
assert.equal(row.level, "warn");
|
||||
assert.match(row.detail, /3\.7/);
|
||||
assert.match(row.detail, /brew upgrade tmux/);
|
||||
assert.ok(row.detail.includes(tmux.upgradeAdvice("2.9")), row.detail);
|
||||
});
|
||||
|
||||
it("warns when a new tmux is still talking to an older running server", () => {
|
||||
const row = rowWithTmux("3.7c", "3.4");
|
||||
const row = rowWithTmux("3.7c", "2.9");
|
||||
|
||||
assert.equal(row.level, "warn");
|
||||
assert.match(row.detail, /server is 3\.4/);
|
||||
assert.match(row.detail, /server is 2\.9/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,8 @@ describe("pty-attach", () => {
|
||||
|
||||
it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => {
|
||||
const ws = makeFakeWs();
|
||||
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
// Linux: a Windows attach also says `wheel` first (its own test below).
|
||||
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform: "linux" });
|
||||
|
||||
fakePty.__emitter.emit("data", "hello from claude");
|
||||
assert.equal(ws.__sent.length, 1);
|
||||
@@ -269,4 +270,21 @@ describe("attachBinary on Windows", () => {
|
||||
}
|
||||
tmux.__reset();
|
||||
});
|
||||
|
||||
it("tells a Windows attach to send the wheel itself (psmux never tracks the mouse)", () => {
|
||||
for (const [platform, told] of [
|
||||
["win32", true],
|
||||
["linux", false],
|
||||
]) {
|
||||
const ws = makeFakeWs();
|
||||
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform });
|
||||
const said = ws.__sent.filter((m) => !m.binary).map((m) => JSON.parse(m.data));
|
||||
assert.equal(
|
||||
said.some((m) => m.type === "wheel"),
|
||||
told,
|
||||
platform
|
||||
);
|
||||
ptyAttach.__resetGroups();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +173,34 @@ describe("/api/run", () => {
|
||||
assert.deepEqual(body, { ok: true });
|
||||
});
|
||||
|
||||
it("DELETE /:id?archive=1 archives the run's session; a plain DELETE (Sleep) keeps it", async () => {
|
||||
const { db } = require("../db");
|
||||
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-stop", TMP);
|
||||
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-sleep", TMP);
|
||||
const run = db.prepare(
|
||||
"INSERT OR REPLACE INTO dashboard_runs (id, session_id, cwd, status) VALUES (?, ?, ?, 'running')"
|
||||
);
|
||||
const archivedAt = (id) =>
|
||||
db.prepare("SELECT archived_at FROM sessions WHERE id = ?").get(id).archived_at;
|
||||
tmux.__setExecImpl(() => "");
|
||||
|
||||
run.run("agenthub-lane-9-1", "sess-sleep", TMP);
|
||||
const slept = await fetchJson("/api/run/agenthub-lane-9-1", { method: "DELETE" });
|
||||
assert.deepEqual(slept.body, { ok: true });
|
||||
assert.equal(archivedAt("sess-sleep"), null);
|
||||
|
||||
run.run("agenthub-lane-9-2", "sess-stop", TMP);
|
||||
const stopped = await fetchJson("/api/run/agenthub-lane-9-2?archive=1", { method: "DELETE" });
|
||||
assert.equal(stopped.status, 200);
|
||||
assert.deepEqual(stopped.body, { ok: true, archived: "sess-stop" });
|
||||
assert.ok(archivedAt("sess-stop"));
|
||||
|
||||
// A pane with no known session (a shell) archives nothing.
|
||||
run.run("agenthub-lane-9-3", null, TMP);
|
||||
const shell = await fetchJson("/api/run/agenthub-lane-9-3?archive=1", { method: "DELETE" });
|
||||
assert.deepEqual(shell.body, { ok: true, archived: null });
|
||||
});
|
||||
|
||||
it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
@@ -190,6 +218,15 @@ describe("/api/run", () => {
|
||||
tmux.__setExecImpl(() => "tmux 3.4");
|
||||
const { body } = await fetchJson("/api/run/tmux");
|
||||
assert.equal(body.available, true);
|
||||
// The version and this platform's upgrade advice (null when new enough).
|
||||
assert.equal(body.version, "3.4");
|
||||
assert.equal(body.upgrade, tmux.upgradeAdvice("3.4"));
|
||||
});
|
||||
|
||||
it("GET /tmux says to upgrade a version older than the platform asks for", async () => {
|
||||
tmux.__setExecImpl(() => "tmux 2.9");
|
||||
const { body } = await fetchJson("/api/run/tmux");
|
||||
assert.match(body.upgrade, /older than/);
|
||||
});
|
||||
|
||||
it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
|
||||
|
||||
@@ -326,4 +326,74 @@ describe("tmux wrapper", () => {
|
||||
});
|
||||
assert.equal(tmux.isTmuxAvailable(), false);
|
||||
});
|
||||
it("upgradeAdvice asks each platform for its own minimum, with its own command", () => {
|
||||
// Windows: psmux before 3.4.0 (the WinGet `tmux`).
|
||||
assert.match(
|
||||
tmux.upgradeAdvice("3.3.2", "win32"),
|
||||
/psmux 3\.3\.2 .*3\.4\.0.*winget upgrade marlocarlo\.psmux/
|
||||
);
|
||||
assert.equal(tmux.upgradeAdvice("3.4.0", "win32"), null);
|
||||
assert.equal(tmux.upgradeAdvice("3.10.1", "win32"), null);
|
||||
// macOS / Linux: tmux before 3.7 (pane scrollbars).
|
||||
assert.match(tmux.upgradeAdvice("3.4", "darwin"), /brew upgrade tmux/);
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
|
||||
assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
|
||||
assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
|
||||
// Nothing to compare: no advice rather than a wrong one.
|
||||
assert.equal(tmux.upgradeAdvice(null, "linux"), 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+209
-37
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Whose identity is decided by an ACCOUNT, never by the request:
|
||||
* - a member (S3, `run-as.js`): everything lives in their own home, under
|
||||
* `~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
|
||||
* `~/.config/agenthub/` — `ssh/id_ed25519` and/or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
|
||||
* (git's own `credential-store` format) — written only AS the member
|
||||
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
|
||||
* Their git runs as them, so it reads these itself; no secret ever crosses
|
||||
@@ -20,6 +20,11 @@
|
||||
* repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a
|
||||
* `credential.helper=store --file=<the member's store>` — so an agent's push
|
||||
* from a pane uses the same login without touching the user's global config.
|
||||
* 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
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -64,7 +69,8 @@ function accountForRequest(req) {
|
||||
/**
|
||||
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
|
||||
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only
|
||||
* `ssh-rsa`). One key per account at a time, in the file of its type.
|
||||
* `ssh-rsa`). An account may hold one of EACH, in the file of its type, and
|
||||
* every ssh git runs offers both (ed25519 first).
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
@@ -208,38 +214,48 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `{type, file}` of the account's key (ed25519 first), or null. */
|
||||
function currentKey(account) {
|
||||
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
|
||||
function currentKeys(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const keys = [];
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const publicKey = readText(account, `${file}.pub`);
|
||||
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, file)) continue;
|
||||
return { type, file, publicKey: publicKey.trim() };
|
||||
keys.push({ type, file, publicKey: publicKey.trim() });
|
||||
}
|
||||
return null;
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The private key file a clone or fetch uses, or null. */
|
||||
const keyFile = (account) => currentKey(account)?.file ?? null;
|
||||
/** The private key files a clone or fetch offers (ed25519 first), maybe none. */
|
||||
const keyFiles = (account) => currentKeys(account).map((k) => k.file);
|
||||
|
||||
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
||||
function sshKeyInfo(account) {
|
||||
const k = currentKey(account);
|
||||
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
|
||||
/** ssh options naming `files` — `-i` each, in order. */
|
||||
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
|
||||
|
||||
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
function sshKeysInfo(account) {
|
||||
return currentKeys(account).map((k) => ({
|
||||
type: k.type,
|
||||
publicKey: k.publicKey,
|
||||
fingerprint: fingerprint(k.publicKey),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The account's first key (ed25519 before rsa), or null — what `ssh` meant before two. */
|
||||
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
|
||||
|
||||
/**
|
||||
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
|
||||
* one is replaced only with `replace` — and a key of the other type goes, so
|
||||
* there is one key per account.
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa); an
|
||||
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
|
||||
* other type is left alone — a host refusing one type keeps the other.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (sshKeyInfo(account) && !replace) {
|
||||
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
|
||||
if (currentKeys(account).some((k) => k.type === type) && !replace) {
|
||||
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
|
||||
}
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
@@ -251,22 +267,26 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
for (const [other, name] of Object.entries(KEY_FILES)) {
|
||||
if (other === type) continue;
|
||||
const f = path.join(sshDir, name);
|
||||
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
|
||||
removeFile(account, f);
|
||||
removeFile(account, `${f}.pub`);
|
||||
}
|
||||
}
|
||||
return sshKeyInfo(account);
|
||||
// A member's home is AgentHub's to set up: their terminal git uses the key
|
||||
// too, unless they wrote a core.sshCommand of their own. The host's is
|
||||
// its admin's choice (the Settings switch).
|
||||
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
|
||||
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
|
||||
}
|
||||
|
||||
function removeSshKey(account) {
|
||||
const k = currentKey(account);
|
||||
if (!k) return false;
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
/**
|
||||
* Delete the account's key of `type`, or every key when `type` is omitted;
|
||||
* false when there was none. The terminal sshCommand goes with the last key.
|
||||
*/
|
||||
async function removeSshKey(account, type) {
|
||||
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
|
||||
if (!gone.length) return false;
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
}
|
||||
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -386,6 +406,142 @@ async function setAuthor(account, { name, email }) {
|
||||
return getAuthor(account);
|
||||
}
|
||||
|
||||
// ── The key in the account's own terminal (`~/.gitconfig` core.sshCommand) ──
|
||||
|
||||
/**
|
||||
* The `core.sshCommand` that makes a plain `git` in the account's terminal
|
||||
* use the AgentHub key (issue #82: a clone typed in a pane offered only
|
||||
* `~/.ssh/id_*` and was refused). Both key files are named, so swapping the
|
||||
* key's type needs no rewrite; ssh skips a missing `IdentityFile` silently.
|
||||
* Naming one drops ssh's default list, so the defaults are named again after
|
||||
* ours — a key the user set up in `~/.ssh/` keeps working.
|
||||
*/
|
||||
function terminalSshCommand(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
|
||||
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
|
||||
return ["ssh", ...ours, ...defaults].join(" ");
|
||||
}
|
||||
|
||||
/** The account's global `core.sshCommand`, or "" when unset. */
|
||||
async function globalSshCommand(account) {
|
||||
const { git } = require("./worktree");
|
||||
return git(homeOf(account), ["config", "--global", "--get", "core.sshCommand"]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account's terminal git uses the AgentHub key: `on` (our
|
||||
* sshCommand), `off` (none set) or `foreign` (one the user wrote — never
|
||||
* touched).
|
||||
*/
|
||||
async function terminalSshState(account) {
|
||||
const cmd = await globalSshCommand(account);
|
||||
if (!cmd) return "off";
|
||||
return cmd === terminalSshCommand(account) ? "on" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the AgentHub key on/off for the account's terminal git. On refuses
|
||||
* over a sshCommand the user wrote (`EFOREIGNSSH`); off removes only ours.
|
||||
* Neither value is a secret, so it may cross sudo's argv.
|
||||
*/
|
||||
async function setTerminalSsh(account, on) {
|
||||
const { git } = require("./worktree");
|
||||
const state = await terminalSshState(account);
|
||||
if (on) {
|
||||
if (state === "foreign") {
|
||||
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
|
||||
}
|
||||
if (state === "off") {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"core.sshCommand",
|
||||
terminalSshCommand(account),
|
||||
]);
|
||||
}
|
||||
} else if (state === "on") {
|
||||
await git(homeOf(account), ["config", "--global", "--unset", "core.sshCommand"]);
|
||||
}
|
||||
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) ─────────
|
||||
|
||||
/**
|
||||
@@ -409,17 +565,18 @@ async function sshConfigFor(dir) {
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
if (own) return [];
|
||||
let account;
|
||||
try {
|
||||
account = accountForDir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const key = keyFile(account);
|
||||
// Our own terminal sshCommand (global) is not the repo's choice: it has no
|
||||
// BatchMode, so it would leave a dashboard fetch waiting on a prompt.
|
||||
if (own && own !== terminalSshCommand(account)) return [];
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -526,12 +683,20 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const key = keyFile(account);
|
||||
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
|
||||
const keys = keyFiles(account);
|
||||
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
||||
if (key) persist.push("-c", `core.sshCommand=${base}`);
|
||||
if (keys.length) {
|
||||
// Persisted by FILE name, both types: a key made later is offered too
|
||||
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
|
||||
const { sshDir } = paths(account);
|
||||
const all = Object.values(KEY_FILES)
|
||||
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`)
|
||||
.join("");
|
||||
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
|
||||
}
|
||||
} else if (account.kind === "member") {
|
||||
const k = hostKey("https", remote.host);
|
||||
const before = readStore(account, { strict: true });
|
||||
@@ -617,8 +782,15 @@ module.exports = {
|
||||
makeRsaKeyPair,
|
||||
fingerprint,
|
||||
sshKeyInfo,
|
||||
sshKeysInfo,
|
||||
generateSshKey,
|
||||
removeSshKey,
|
||||
terminalSshCommand,
|
||||
terminalSshState,
|
||||
setTerminalSsh,
|
||||
listSshUsers,
|
||||
setSshUser,
|
||||
removeSshUser,
|
||||
listHttps,
|
||||
saveHttps,
|
||||
removeHttps,
|
||||
|
||||
@@ -136,6 +136,11 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
group.viewers.add(me);
|
||||
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
||||
else group.driver = me;
|
||||
// psmux never turns mouse tracking on for its client (not even for a pane
|
||||
// app that asked), so xterm.js would never report the wheel and the pane
|
||||
// could not scroll. psmux does act on a wheel report it receives: the
|
||||
// browser sends one itself (TerminalView).
|
||||
if (platform === "win32") tell(me, { type: "wheel" });
|
||||
|
||||
// Backpressure: a slow viewer (a remote browser on a thin link) must not
|
||||
// queue a chatty pane's output in this process without bound. Past 1 MB
|
||||
|
||||
+151
-11
@@ -27,10 +27,16 @@
|
||||
* 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)
|
||||
* 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>
|
||||
*/
|
||||
|
||||
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
|
||||
// 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.
|
||||
// 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).
|
||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
|
||||
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
|
||||
// Bounded like a member's call (below): our server can spin too — a 3.6
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// 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. */
|
||||
function __setAccountImpl(fn, member) {
|
||||
accountImpl = fn;
|
||||
@@ -159,7 +260,7 @@ function accountOf(name) {
|
||||
/** Run a tmux command on the server that holds session `name`. */
|
||||
function onServerOf(name, args, input) {
|
||||
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
|
||||
@@ -174,12 +275,15 @@ function __setExecImpl(fn, { exact = true } = {}) {
|
||||
exactTargets = exact;
|
||||
listed = new Map();
|
||||
listGen++;
|
||||
unreachable = new Map();
|
||||
}
|
||||
function __setScopeImpl(fn) {
|
||||
scopeImpl = fn;
|
||||
}
|
||||
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;
|
||||
accountImpl = (name) => require("./run-as").accountForSession(name);
|
||||
memberImpl = realMemberImpl;
|
||||
@@ -217,7 +321,7 @@ function listOutput(acct = null) {
|
||||
return hit.out;
|
||||
}
|
||||
try {
|
||||
const out = sessionNames(execImpl(LIST_ARGS));
|
||||
const out = sessionNames(hostCall(LIST_ARGS));
|
||||
listed.set("", { at: Date.now(), out });
|
||||
return out;
|
||||
} catch (err) {
|
||||
@@ -324,7 +428,7 @@ function exact() {
|
||||
if (exactTargets !== null) return exactTargets;
|
||||
let first;
|
||||
try {
|
||||
first = sessionNames(execImpl(LIST_ARGS))
|
||||
first = sessionNames(hostCall(LIST_ARGS))
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.find(Boolean);
|
||||
@@ -334,7 +438,7 @@ function exact() {
|
||||
if (!first) return true;
|
||||
const answers = (t) => {
|
||||
try {
|
||||
execImpl(["has-session", "-t", t]);
|
||||
hostCall(["has-session", "-t", t]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -379,7 +483,7 @@ function hasSession(name) {
|
||||
/** Our running tmux server's pid, or null when none answers. */
|
||||
function serverPid() {
|
||||
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;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -435,10 +539,10 @@ function newSession({ name, cwd, argv, env }) {
|
||||
scopeImpl(args);
|
||||
} catch {
|
||||
// No user manager (SSH without linger, no systemd): the plain start.
|
||||
if (!hasSession(name)) execImpl(args);
|
||||
if (!hasSession(name)) hostCall(args);
|
||||
}
|
||||
} else {
|
||||
execImpl(args);
|
||||
hostCall(args);
|
||||
}
|
||||
try {
|
||||
enableMouse(name);
|
||||
@@ -695,11 +799,45 @@ function clientVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// The oldest tmux AgentHub asks for, and how each platform upgrades it.
|
||||
// Windows' is psmux: before 3.4.0 a dashboard terminal stalled until a
|
||||
// refresh, and fixes for frame push, writes and synchronized output landed in
|
||||
// 3.3.6–3.4.0. Elsewhere 3.7, the first with pane scrollbars (enableMouse).
|
||||
const UPGRADES = {
|
||||
win32: {
|
||||
min: [3, 4, 0],
|
||||
name: "psmux",
|
||||
how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux",
|
||||
},
|
||||
darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" },
|
||||
linux: {
|
||||
min: [3, 7, 0],
|
||||
name: "tmux",
|
||||
how:
|
||||
"distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
|
||||
"https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " +
|
||||
"./configure && make && sudo make install)",
|
||||
},
|
||||
};
|
||||
|
||||
/** What to tell the user when `version` is older than their platform asks
|
||||
* for; null when it is new enough or cannot be compared. Advice only —
|
||||
* AgentHub never installs or restarts tmux. */
|
||||
function upgradeAdvice(version = clientVersion(), platform = process.platform) {
|
||||
const want = UPGRADES[platform];
|
||||
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(version || "");
|
||||
if (!want || !m) return null;
|
||||
const have = [m[1], m[2], m[3] || 0].map(Number);
|
||||
const i = have.findIndex((n, k) => n !== want.min[k]);
|
||||
if (i === -1 || have[i] > want.min[i]) return null;
|
||||
return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`;
|
||||
}
|
||||
|
||||
/** The RUNNING server's version, null when none runs. A server started before
|
||||
* an upgrade keeps its old version until every tmux session has closed. */
|
||||
function serverVersion() {
|
||||
try {
|
||||
return execImpl(["display-message", "-p", "#{version}"]).trim() || null;
|
||||
return hostCall(["display-message", "-p", "#{version}"]).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -707,6 +845,7 @@ function serverVersion() {
|
||||
|
||||
module.exports = {
|
||||
__setScopeImpl,
|
||||
serverExeFor,
|
||||
session,
|
||||
pane,
|
||||
hasSession,
|
||||
@@ -725,6 +864,7 @@ module.exports = {
|
||||
isTmuxAvailable,
|
||||
clientVersion,
|
||||
serverVersion,
|
||||
upgradeAdvice,
|
||||
serverPid,
|
||||
memberAttach,
|
||||
__setExecImpl,
|
||||
|
||||
@@ -268,7 +268,7 @@ const paths = {
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}",
|
||||
"{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -276,18 +276,41 @@ const paths = {
|
||||
"/api/me/git/ssh-key": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Make (or with `replace`, replace) the SSH key",
|
||||
summary: "Make (or with `replace`, replace) the SSH key of `type`; the other type is kept",
|
||||
operationId: "generateSshKey",
|
||||
requestBody: json({ replace: { type: "boolean" } }),
|
||||
requestBody: json({
|
||||
replace: { type: "boolean" },
|
||||
type: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
}),
|
||||
responses: { 201: { description: "The identity." } },
|
||||
},
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Delete the SSH key",
|
||||
summary: "Delete the SSH key of ?type=ed25519|rsa, or every key",
|
||||
operationId: "deleteSshKey",
|
||||
parameters: [
|
||||
{
|
||||
name: "type",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
},
|
||||
],
|
||||
responses: { 200: { description: "The identity." } },
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-terminal": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Use the SSH key for git in the account's own terminal (core.sshCommand)",
|
||||
operationId: "setTerminalSsh",
|
||||
requestBody: json({ on: { type: "boolean" } }, ["on"]),
|
||||
responses: {
|
||||
200: { description: "The identity; `ssh.terminal` is on, off or foreign." },
|
||||
409: { description: "EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/https": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -585,6 +585,12 @@ const schemas = {
|
||||
required: ["ok"],
|
||||
properties: {
|
||||
ok: { type: "boolean", enum: [true], example: true },
|
||||
archived: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1188,6 +1194,14 @@ const paths = {
|
||||
description: "Run id (RunHandle.id).",
|
||||
example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c",
|
||||
},
|
||||
{
|
||||
name: "archive",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"`1` (the UI's Stop) also archives the session the pane held, so it leaves its workspace's lists. Omitted (the UI's Sleep, the CLI) the session stays listed, to be resumed later.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
|
||||
* mounted at `/api/me/git`: the SSH key a clone uses (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 —
|
||||
* `hasToken` only, never a password), and the commit author (`user.name` /
|
||||
* `hasToken` only, never a password), whether the account's own terminal
|
||||
* 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
|
||||
* 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
|
||||
@@ -27,6 +29,8 @@ const STATUS = {
|
||||
EBADAUTHOR: 400,
|
||||
ESTOREUNREADABLE: 409,
|
||||
EBADKEYTYPE: 400,
|
||||
EFOREIGNSSH: 409,
|
||||
EBADSSHUSER: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -36,10 +40,16 @@ function fail(res, e) {
|
||||
}
|
||||
|
||||
async function view(account) {
|
||||
const keys = identity.sshKeysInfo(account);
|
||||
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
|
||||
return {
|
||||
account: account.kind,
|
||||
writable: account.kind === "member" || account.writable !== false,
|
||||
ssh: identity.sshKeyInfo(account),
|
||||
// `ssh`: the first key, as before there could be two; `sshKeys`: all.
|
||||
ssh: keys[0] ? { ...keys[0], terminal } : null,
|
||||
sshKeys: keys,
|
||||
sshTerminal: terminal ?? null,
|
||||
sshUsers: await identity.listSshUsers(account),
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
};
|
||||
@@ -81,7 +91,8 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!identity.removeSshKey(account)) {
|
||||
const type = req.query.type === undefined ? undefined : String(req.query.type);
|
||||
if (!(await identity.removeSshKey(account, type))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
@@ -90,6 +101,49 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a plain `git` in the account's own terminal uses the AgentHub key
|
||||
* (`core.sshCommand` in its `~/.gitconfig`, issue #82). `{on: boolean}`.
|
||||
*/
|
||||
router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!identity.sshKeyInfo(account) && req.body?.on === true) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
await identity.setTerminalSsh(account, req.body?.on === true);
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
|
||||
+30
-1
@@ -423,7 +423,14 @@ router.get("/binary", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/tmux", (_req, res) => {
|
||||
res.json({ available: tmux.isTmuxAvailable() });
|
||||
// `upgrade`: this platform's advice when the binary is older than AgentHub
|
||||
// asks for (tmux.upgradeAdvice), else null. The Workspace shows it.
|
||||
const version = tmux.clientVersion();
|
||||
res.json({
|
||||
available: version !== null,
|
||||
version,
|
||||
upgrade: version ? tmux.upgradeAdvice(version) : null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
@@ -619,9 +626,31 @@ router.delete("/:id", (req, res) => {
|
||||
if (!ok) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
// `?archive=1` is the UI's Stop: the session the pane held leaves its
|
||||
// workspace's lists as well. Sleep (and the CLI) omit it, so the session
|
||||
// stays listed to be resumed later. A pane with no known session (a shell,
|
||||
// a Codex pane whose session was never learned) archives nothing.
|
||||
if (req.query.archive === "1") {
|
||||
return res.json({ ok: true, archived: archiveRunSession(req, req.params.id) });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
/** Archive the session a run held; returns its id, or null when none. */
|
||||
function archiveRunSession(req, runId) {
|
||||
const sessionId = runs.getRun(runId)?.sessionId;
|
||||
if (!sessionId || !scope.canSeeSession(req, sessionId)) return null;
|
||||
const { db, stmts } = require("../db");
|
||||
const changed = db
|
||||
.prepare(
|
||||
"UPDATE sessions SET archived_at = COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) WHERE id = ?"
|
||||
)
|
||||
.run(sessionId).changes;
|
||||
if (!changed) return null;
|
||||
require("../websocket").broadcast("session_updated", stmts.getSession.get(sessionId));
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
// Shared with routes/lanes.js: lane actions spawn processes through the same
|
||||
// run-spawner, so they must sit behind the same loopback/same-origin check.
|
||||
|
||||
Reference in New Issue
Block a user