feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix

- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:51:26 +07:00
parent 58be04a4e7
commit 0a1dcfeb7f
28 changed files with 813 additions and 225 deletions
+92 -15
View File
@@ -16,9 +16,13 @@
* — and the poll waits for the old server to actually go DOWN first, because it * — 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 * keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a * 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 * background process can do for it. Escape, the backdrop and × close it for
* for this page load only (never while applying); "Skip this version" is the * this page load only (never while applying); "Skip this version" is the one
* one persistent, per-version dismiss. * 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 * ## State sources
* - Initial fetch via `api.updates.status()` on mount. * - Initial fetch via `api.updates.status()` on mount.
@@ -81,7 +85,18 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
@@ -89,6 +104,7 @@ import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog"; import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate"; import { usePermissions } from "./AuthGate";
import { startInFreeTab } from "./lanes/startSession";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */ /** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-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}. */ /** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload { function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; 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 { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null); const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha); 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". // persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null); const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -139,6 +181,8 @@ export function UpdateNotifier() {
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => { const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s); setStatus(s);
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
dismissedSha !== dismissKey && dismissedSha !== dismissKey &&
closedKey !== dismissKey closedKey !== dismissKey
); );
const busy = applying || restarting; const busy = applying || restarting || fixing;
const closeForNow = useCallback(() => { const closeForNow = useCallback(() => {
if (busy || !dismissKey) return; 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; if (!show || !status) return null;
const refLabel = status.remote_ref || "origin"; const refLabel = status.remote_ref || "origin";
@@ -493,16 +560,9 @@ export function UpdateNotifier() {
title={t("skipVersionHint")} title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed" className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
> >
<SkipForward className="w-3.5 h-3.5" aria-hidden />
{t("skipVersion")} {t("skipVersion")}
</button> </button>
<button
type="button"
onClick={closeForNow}
disabled={busy}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("later")}
</button>
{status.manual_command ? ( {status.manual_command ? (
<button <button
type="button" type="button"
@@ -514,11 +574,28 @@ export function UpdateNotifier() {
{copied ? t("copied") : t("copy")} {copied ? t("copied") : t("copy")}
</button> </button>
) : null} ) : 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) ? ( {isAutoApplicable(status.situation) ? (
<button <button
type="button" type="button"
onClick={applyNow} onClick={applyNow}
disabled={applying || restarting} disabled={busy}
className="btn-primary disabled:opacity-70" className="btn-primary disabled:opacity-70"
> >
{applying || restarting ? ( {applying || restarting ? (
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
const statusMock = vi.fn(); const statusMock = vi.fn();
const checkMock = vi.fn(); const checkMock = vi.fn();
const applyMock = 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) => { vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
check: (...args: unknown[]) => checkMock(...args), check: (...args: unknown[]) => checkMock(...args),
apply: (...args: unknown[]) => applyMock(...args), apply: (...args: unknown[]) => applyMock(...args),
}, },
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
}, },
}; };
}); });
@@ -56,6 +69,9 @@ beforeEach(() => {
statusMock.mockReset().mockResolvedValue(BASE_STATUS); statusMock.mockReset().mockResolvedValue(BASE_STATUS);
checkMock.mockReset().mockResolvedValue(BASE_STATUS); checkMock.mockReset().mockResolvedValue(BASE_STATUS);
applyMock.mockReset(); applyMock.mockReset();
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
navigateMock.mockReset();
try { try {
localStorage.clear(); localStorage.clear();
} catch { } catch {
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
it("Check for updates re-opens a modal closed with Later", async () => { it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />); 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(() => await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument() 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(); 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"); let menu = screen.getByTestId("session-menu-s-old");
// Ended: no pane to split or close. // Ended: no pane to split or close.
expect(within(menu).queryByText("Open in split")).toBeNull(); 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")); await user.click(within(menu).getByText("Rename"));
expect(screen.queryByTestId("session-menu-s-old")).toBeNull(); expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument(); 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")); await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true }); 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 right(screen.getByTestId("workspace-session-s-live"));
await user.click( 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 () => { it("archiving a worktree session removes its worktree first, after asking", async () => {
+16 -22
View File
@@ -5,19 +5,20 @@
* does: open it the way its click does (a live pane attaches, an ended session * 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, * 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 * 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 * its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
* tab's X does — after the same confirm for an AI tab, which may be mid-turn). * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; 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 type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast"; import { canSleep, endRuns } from "../../lib/stopSession";
export function SessionMenu({ export function SessionMenu({
laneId, laneId,
@@ -50,24 +51,12 @@ export function SessionMenu({
onClose(); onClose();
fn(); 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; if (!run) return;
onClose(); onClose();
if ( void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
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);
}
}; };
return ( return (
@@ -105,8 +94,13 @@ export function SessionMenu({
{(run || onArchive || onUnarchive) && <MenuSeparator />} {(run || onArchive || onUnarchive) && <MenuSeparator />}
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>} {onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</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 && ( {run && (
<MenuItem danger onSelect={() => void closeTab()}> <MenuItem danger onSelect={() => endTab(false)}>
{t("tabs.close")} {t("tabs.close")}
</MenuItem> </MenuItem>
)} )}
+33 -28
View File
@@ -8,8 +8,11 @@
* prompt it started on, else its tool and number; a combo tab shows every * 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 / * 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 * 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 * X stops every session in it AND archives them (`endRuns`,
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow * `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 * 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 * 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}`) * (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
@@ -48,13 +51,13 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; 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 { api } from "../../lib/api";
import { useSettingsHref } from "../../lib/settingsLink"; import { useSettingsHref } from "../../lib/settingsLink";
import type { RunHandle } from "../../lib/api"; import type { RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types"; import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { canSleep, endRuns } from "../../lib/stopSession";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools"; 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 // Stop archives the tab's sessions; Sleep keeps them listed to resume
// shell holds no agent. // (`endRuns` asks first for an AI pane, which may be mid-turn).
const close = async (tab: Tab) => { const close = async (tab: Tab, sleep = false) => {
if ( for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
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);
}
}
}; };
const startRename = (r: RunHandle) => { const startRename = (r: RunHandle) => {
@@ -638,6 +621,20 @@ export function ConsoleTabs({
<span className="truncate">{tabLabel(tab)}</span> <span className="truncate">{tabLabel(tab)}</span>
{state && <RunDot state={state} />} {state && <RunDot state={state} />}
</button> </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 <button
type="button" type="button"
data-testid={`console-tab-close-${tab.key}`} data-testid={`console-tab-close-${tab.key}`}
@@ -785,6 +782,14 @@ export function ConsoleTabs({
</> </>
)} )}
<MenuSeparator /> <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))}> <MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")} {t("tabs.close")}
</MenuItem> </MenuItem>
+26 -2
View File
@@ -10,7 +10,9 @@
* there, on its middle to swap), pick another live session for it, split it * 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 * 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 * 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 * 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 * 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. * shows its state dot, as its tab does.
@@ -40,11 +42,13 @@ import {
GripVertical, GripVertical,
MessageSquare, MessageSquare,
Minimize2, Minimize2,
Moon,
Rows2, Rows2,
SquareTerminal, SquareTerminal,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { RunHandle } from "../../lib/api"; import type { RunHandle } from "../../lib/api";
import { canSleep } from "../../lib/stopSession";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { import {
EMPTY_GROUPS, EMPTY_GROUPS,
@@ -402,6 +406,7 @@ export function PaneGrid({
onNewSplit, onNewSplit,
stateOf, stateOf,
onStop, onStop,
onSleep,
keyOf = (slot) => slot, keyOf = (slot) => slot,
kept = [], kept = [],
}: { }: {
@@ -410,8 +415,10 @@ export function PaneGrid({
keyOf?: (slot: number) => number; keyOf?: (slot: number) => number;
/** Panes kept mounted, always hidden — never in the layout. */ /** Panes kept mounted, always hidden — never in the layout. */
kept?: { key: number; node: ReactNode }[]; 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; 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). */ /** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null; stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */ /** 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" /> <Minimize2 className="h-3.5 w-3.5" />
</button> </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 && ( {run && onStop && (
<button <button
type="button" type="button"
@@ -696,6 +715,11 @@ export function PaneGrid({
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}> <MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
{t("panes.closeShort")} {t("panes.closeShort")}
</MenuItem> </MenuItem>
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
{t("panes.sleep")}
</MenuItem>
)}
{onStop && runOf(paneCtx.menu.item) && ( {onStop && runOf(paneCtx.menu.item) && (
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}> <MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
{t("panes.stop")} {t("panes.stop")}
@@ -4,9 +4,11 @@
* commit author (name + email in the account's global git config — prefilled * commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, since a member's commits fail without * from Settings → Account while unset, since a member's commits fail without
* one), the SSH * one), the SSH
* key a clone (Add workspace → Clone) and the pushes from that clone use — * keys a clone (Add workspace → Clone) and the pushes from that clone use —
* make one, copy its public half to add to GitHub/GitLab, replace or delete * an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it — and, for a user with their own Linux account (S3), their own HTTPS * it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials * logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and * table above it, so this lists them only for a member. The private key and
* every token are write-only: the server never returns them. A host identity * every token are write-only: the server never returns them. A host identity
@@ -21,6 +23,7 @@ import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate"; import { useAuth } from "../AuthGate";
import { Switch } from "../Switch";
const INPUT = 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"; "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";
@@ -65,23 +68,24 @@ export function GitIdentitySettings() {
}; };
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA. // ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// 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 [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519"); const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
const typeTaken = keys.some((k) => (k.type ?? "ed25519") === keyType);
const generate = async () => { const generate = async () => {
if ( if (
id?.ssh && typeTaken &&
!(await confirmDialog({ !(await confirmDialog({
title: t("gitIdentity.replaceConfirm"), title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"), confirmLabel: t("gitIdentity.replace"),
})) }))
) )
return; return;
await run( await run(() => api.gitIdentity.generateKey(typeTaken, keyType), t("gitIdentity.keyMade"));
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
t("gitIdentity.keyMade")
);
}; };
const removeKey = async () => { const removeKey = async (type?: "ed25519" | "rsa") => {
if ( if (
!(await confirmDialog({ !(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"), title: t("gitIdentity.removeKeyConfirm"),
@@ -89,7 +93,7 @@ export function GitIdentitySettings() {
})) }))
) )
return; return;
await run(() => api.gitIdentity.removeKey()); await run(() => api.gitIdentity.removeKey(type));
}; };
const saveLogin = async () => { const saveLogin = async () => {
@@ -151,7 +155,7 @@ export function GitIdentitySettings() {
return ( return (
<div className="space-y-4"> <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")} {member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p> </p>
@@ -162,7 +166,7 @@ export function GitIdentitySettings() {
)} )}
<form <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) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void saveAuthor(); void saveAuthor();
@@ -202,7 +206,7 @@ export function GitIdentitySettings() {
)} )}
</form> </form>
<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"> <div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden /> <KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary"> <h3 className="flex-1 text-xs font-semibold text-fg-primary">
@@ -222,46 +226,64 @@ export function GitIdentitySettings() {
)} )}
{id.writable && ( {id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}> <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" />} {typeTaken ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")} {typeTaken ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button> </button>
)} )}
</div> </div>
{id.ssh ? ( {keys.length ? (
<> <>
<textarea {keys.map((k) => (
readOnly <div key={k.type ?? "ed25519"} className="space-y-1.5">
aria-label={t("gitIdentity.publicKey")} <textarea
value={id.ssh.publicKey} readOnly
rows={3} aria-label={`${t("gitIdentity.publicKey")} ${k.type === "rsa" ? "RSA" : "ED25519"}`}
onFocus={(e) => e.currentTarget.select()} value={k.publicKey}
className={`${INPUT} resize-none break-all font-mono text-[11px]`} rows={k.type === "rsa" ? 4 : 2}
/> onFocus={(e) => e.currentTarget.select()}
<div className="flex flex-wrap items-center gap-2"> className={`${INPUT} resize-none break-all font-mono text-[11px]`}
<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 · " : ""} <div className="flex flex-wrap items-center gap-2">
{id.ssh.fingerprint} <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
</span> {k.type === "rsa" ? "RSA 4096 · " : "ED25519 · "}
<button {k.fingerprint}
type="button" </span>
className={BTN} <button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
onClick={() => void copyText(id.ssh?.publicKey ?? "")} <Copy className="h-3.5 w-3.5" />
> {t("gitIdentity.copy")}
<Copy className="h-3.5 w-3.5" /> </button>
{t("gitIdentity.copy")} {id.writable && (
</button> <button
{id.writable && ( type="button"
<button className={`${BTN} hover:text-status-danger`}
type="button" disabled={busy}
className={`${BTN} hover:text-status-danger`} onClick={() => void removeKey(k.type)}
disabled={busy} >
onClick={() => void removeKey()} <Trash2 className="h-3.5 w-3.5" />
> {t("gitIdentity.remove")}
<Trash2 className="h-3.5 w-3.5" /> </button>
{t("gitIdentity.remove")} )}
</button> </div>
)} </div>
</div> ))}
{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>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p> <p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</> </>
) : ( ) : (
@@ -270,7 +292,7 @@ export function GitIdentitySettings() {
</div> </div>
{member && ( {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"> <div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary"> <h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")} {t("gitIdentity.httpsTitle")}
@@ -19,6 +19,7 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(), get: vi.fn(),
generateKey: vi.fn(), generateKey: vi.fn(),
removeKey: vi.fn(), removeKey: vi.fn(),
setTerminalSsh: vi.fn(),
saveHttps: vi.fn(), saveHttps: vi.fn(),
saveAuthor: vi.fn(), saveAuthor: vi.fn(),
removeHttps: vi.fn(), removeHttps: vi.fn(),
@@ -51,7 +52,7 @@ describe("GitIdentitySettings", () => {
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" })); await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled(); expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519"); expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey); expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
}); });
it("copies the public key and asks before replacing it", async () => { it("copies the public key and asks before replacing it", async () => {
@@ -97,6 +98,31 @@ describe("GitIdentitySettings", () => {
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument(); expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
}); });
it("adds an RSA key beside the ED25519 one 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] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
);
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
// No RSA key yet: a plain Generate, no replace prompt, the ED25519 key stays.
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
expect(screen.getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("warns while the commit author is unset and saves it", async () => { it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } })); vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view()); vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
@@ -118,7 +144,7 @@ describe("GitIdentitySettings", () => {
view({ account: "host", writable: false, ssh: KEY }) view({ account: "host", writable: false, ssh: KEY })
); );
render(<GitIdentitySettings />); 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: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull(); expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
}); });
+9 -3
View File
@@ -430,7 +430,7 @@
"tabs.new": "New tab", "tabs.new": "New tab",
"tabs.newTerminal": "New terminal", "tabs.newTerminal": "New terminal",
"tabs.terminal": "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.full": "All 10 sessions are in use — stop one first",
"tabs.agentSettings": "Agent settings…", "tabs.agentSettings": "Agent settings…",
"tabs.settings": "Settings", "tabs.settings": "Settings",
@@ -465,7 +465,8 @@
"panes.splitNew.right": "Split right with a new terminal", "panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal", "panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Move to own tab", "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.copyPath": "Copy relative path",
"changes.viewCommit": "View commit", "changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA", "changes.copySha": "Copy commit SHA",
@@ -479,8 +480,13 @@
"tabs.renameHint": "Double-click to rename", "tabs.renameHint": "Double-click to rename",
"tabs.stopTitle": "Stop “{{name}}”?", "tabs.stopTitle": "Stop “{{name}}”?",
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?", "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.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.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it", "changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel", "changes.splitter": "Resize the changes panel",
+5 -2
View File
@@ -910,14 +910,17 @@
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.", "removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
"publicKey": "Public key", "publicKey": "Public key",
"copy": "Copy public key", "copy": "Copy public key",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only). Paste the whole line as one line. The private key never leaves the server.", "addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only, and clone as ssh://<SSH key ID>@git-codecommit…, not git@). Paste the whole line as one line. The private key never leaves the server.",
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.", "noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
"httpsTitle": "HTTPS logins", "httpsTitle": "HTTPS logins",
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.", "noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.", "removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
"keyType": "Key type", "keyType": "Key type",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)", "keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)" "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."
}, },
"gitCreds": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
+5 -1
View File
@@ -31,5 +31,9 @@
"installedTitle": "Version {{version}} is installed but not running", "installedTitle": "Version {{version}} is installed but not running",
"later": "Not now", "later": "Not now",
"skipVersion": "Skip this version", "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"
} }
+9 -3
View File
@@ -430,7 +430,7 @@
"tabs.new": "Tab mới", "tabs.new": "Tab mới",
"tabs.newTerminal": "Terminal mới", "tabs.newTerminal": "Terminal mới",
"tabs.terminal": "Terminal", "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.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
"tabs.agentSettings": "Cài đặt agent…", "tabs.agentSettings": "Cài đặt agent…",
"tabs.settings": "Cài đặt", "tabs.settings": "Cài đặt",
@@ -465,7 +465,8 @@
"panes.splitNew.right": "Chia sang phải với terminal mới", "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.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Tách ra tab riêng", "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.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit", "changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit", "changes.copySha": "Sao chép SHA của commit",
@@ -479,8 +480,13 @@
"tabs.renameHint": "Nhấp đúp để đổi tên", "tabs.renameHint": "Nhấp đúp để đổi tên",
"tabs.stopTitle": "Dừng “{{name}}”?", "tabs.stopTitle": "Dừng “{{name}}”?",
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?", "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.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.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi", "changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi", "changes.splitter": "Đổi độ rộng khung thay đổi",
+5 -2
View File
@@ -910,14 +910,17 @@
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.", "removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
"publicKey": "Public key", "publicKey": "Public key",
"copy": "Copy public key", "copy": "Copy public key",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.", "addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA, và clone bằng ssh://<SSH key ID>@git-codecommit…, không phải git@). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
"noKey": "Chưa có 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ó SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
"httpsTitle": "Đăng nhập HTTPS", "httpsTitle": "Đăng nhập HTTPS",
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.", "noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.", "removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
"keyType": "Loại key", "keyType": "Loại key",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)", "keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)" "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."
}, },
"gitCreds": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
+5 -1
View File
@@ -31,5 +31,9 @@
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy", "installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
"later": "Để sau", "later": "Để sau",
"skipVersion": "Bỏ qua phiên bản này", "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"
} }
+24 -5
View File
@@ -2670,7 +2670,12 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ replace, type }), 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 }) }),
saveHttps: (body: { host: string; username: string; password: string }) => saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }), request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) => removeHttps: (host: string) =>
@@ -2992,9 +2997,13 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ mode }), body: JSON.stringify({ mode }),
}), }),
/** DELETE /api/run/:id - kill the tmux session. */ /** DELETE /api/run/:id - kill the tmux session. `archive` (the UI's Stop)
kill: (id: string) => * also archives the session it held; Sleep leaves it listed to resume. */
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }), 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, /** GET /api/run/interrupted - sessions whose pane died under them (reboot,
* killed tmux, crash), newest first; the caller's own workspaces only. */ * killed tmux, crash), newest first; the caller's own workspaces only. */
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"), interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
@@ -4190,7 +4199,17 @@ export interface GitIdentity {
account: "member" | "host"; account: "member" | "host";
/** False for the host's identity seen by a non-admin. */ /** False for the host's identity seen by a non-admin. */
writable: boolean; 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;
/** The account's commit author (`git config --global`); null fields = unset. */ /** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null }; author: { name: string | null; email: string | null };
https: { https: {
+57
View File
@@ -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;
}
+7 -20
View File
@@ -81,8 +81,9 @@ import type {
} from "../lib/api"; } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types"; import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu"; import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDialog, confirmDiscard } from "../lib/confirm"; import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame"; import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes"; import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
@@ -423,27 +424,12 @@ function ConsoleArea({
else onNewSession?.(); else onNewSession?.();
}; };
// A pane header's X: stops that one session, as a one-pane tab's X does — // A pane header's X stops (and archives) that one session, its moon sleeps
// an AI session may be mid-turn, so it asks first; a shell holds no agent. // it — as a one-pane tab's buttons do (`endRuns` asks first for an AI pane).
const stopPane = async (slot: number) => { const stopPane = async (slot: number, sleep = false) => {
const run = runOf(slot); const run = runOf(slot);
if (!run) return; if (!run) return;
if ( if ((await endRuns([run], { sleep, name: label(run), t })).length) ended(slot);
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);
}
}; };
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+` // 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)} onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf} stateOf={stateOf}
onStop={(slot) => void stopPane(slot)} 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. // Keyed by workspace too, so a kept pane coming back is the same one.
keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)} keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)}
kept={kept} kept={kept}
+18 -6
View File
@@ -766,11 +766,21 @@ describe("console tabs", () => {
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument(); 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 () => { it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]); withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1"); await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-close-0")); 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(() => await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true") expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
); );
@@ -916,8 +926,8 @@ describe("split panes", () => {
withRuns([live(1)]); withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2")); await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle(); await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"); expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2"); expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(shownPanes()).toEqual(["pane-1"]); expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1"); expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
}); });
@@ -932,7 +942,7 @@ describe("split panes", () => {
await userEvent.setup().click(screen.getByTestId("pane-stop-1")); await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
await settle(); await settle();
expect(api.run.kill).toHaveBeenCalledTimes(1); 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(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(screen.queryByTestId("console-tab-1")).toBeNull(); expect(screen.queryByTestId("console-tab-1")).toBeNull();
}); });
@@ -1017,9 +1027,11 @@ describe("split panes", () => {
await right(screen.getByTestId("console-tab-2")); await right(screen.getByTestId("console-tab-2"));
await user.click( 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 () => { it("a split menu starts a new terminal in the first free slot", async () => {
+5 -4
View File
@@ -1042,9 +1042,10 @@ cannot collide on `index.lock`.
#### Your git identity #### Your git identity
``` ```
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] } 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 — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS without replace) → 201, same shape POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND) 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
POST /api/me/git/https Save an https login — Body: { host, username, password } POST /api/me/git/https Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND) DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
@@ -2807,7 +2808,7 @@ POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor) (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? } 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) 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. `: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.
+3 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -562,7 +562,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's. - In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`. - The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo. - A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), 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. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are: - **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files; - the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway; - the AI Proxy settings: no key, only that pane's own token for the gateway;
+92 -7
View File
@@ -267,16 +267,34 @@ describe("/api/me/git", () => {
assert.equal(del.body.ssh, null); 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 () => { it("keeps an RSA 4096 key (AWS CodeCommit) BESIDE the ed25519 one", async () => {
await request("POST", "/api/me/git/ssh-key", {}); const ed = await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" }); const rsa = await request("POST", "/api/me/git/ssh-key", { type: "rsa" });
assert.equal(rsa.status, 201, JSON.stringify(rsa.body)); assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
assert.equal(rsa.body.ssh.type, "rsa"); assert.deepEqual(
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/); 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")); assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
const dir = path.join(ROOT, ".claude", "agenthub-ssh"); const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600); 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")], { const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
encoding: "utf8", encoding: "utf8",
}); });
@@ -317,6 +335,64 @@ 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("git-identity, member account (S3, stubbed)", () => { describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as"); const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree"); const worktree = require("../lib/worktree");
@@ -356,7 +432,12 @@ describe("git-identity, member account (S3, stubbed)", () => {
it("keeps the key and the logins in the member's own home", async () => { it("keeps the key and the logins in the member's own home", async () => {
const account = identity.accountForDir(path.join(HOME, "proj")); const account = identity.accountForDir(path.join(HOME, "proj"));
assert.equal(account.kind, "member"); assert.equal(account.kind, "member");
calls = [];
await identity.generateSshKey(account); 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"); const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600); assert.equal(fs.statSync(key).mode & 0o777, 0o600);
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" }); identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
@@ -381,7 +462,11 @@ describe("git-identity, member account (S3, stubbed)", () => {
args[i - 1], args[i - 1],
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/ /^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"]); assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
}); });
+28
View File
@@ -173,6 +173,34 @@ describe("/api/run", () => {
assert.deepEqual(body, { ok: true }); 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 () => { it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
tmux.__setExecImpl((args) => { tmux.__setExecImpl((args) => {
if (args[0] === "has-session") { if (args[0] === "has-session") {
+132 -37
View File
@@ -5,7 +5,7 @@
* *
* Whose identity is decided by an ACCOUNT, never by the request: * Whose identity is decided by an ACCOUNT, never by the request:
* - a member (S3, `run-as.js`): everything lives in their own home, under * - 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 * (git's own `credential-store` format) — written only AS the member
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`. * (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
* Their git runs as them, so it reads these itself; no secret ever crosses * Their git runs as them, so it reads these itself; no secret ever crosses
@@ -20,6 +20,10 @@
* repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a * 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 * `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. * 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).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -64,7 +68,8 @@ function accountForRequest(req) {
/** /**
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and * The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only * 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_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" }; const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
@@ -208,38 +213,48 @@ function fingerprint(publicKey) {
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`; return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
} }
/** `{type, file}` of the account's key (ed25519 first), or null. */ /** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
function currentKey(account) { function currentKeys(account) {
const { sshDir } = paths(account); const { sshDir } = paths(account);
const keys = [];
for (const type of Object.keys(KEY_FILES)) { for (const type of Object.keys(KEY_FILES)) {
const file = path.join(sshDir, KEY_FILES[type]); const file = path.join(sshDir, KEY_FILES[type]);
const publicKey = readText(account, `${file}.pub`); const publicKey = readText(account, `${file}.pub`);
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue; if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
// The private half is only looked at, never read into this process. // The private half is only looked at, never read into this process.
if (!isOwnFile(account, file)) continue; 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. */ /** The private key files a clone or fetch offers (ed25519 first), maybe none. */
const keyFile = (account) => currentKey(account)?.file ?? null; const keyFiles = (account) => currentKeys(account).map((k) => k.file);
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */ /** ssh options naming `files` — `-i` each, in order. */
function sshKeyInfo(account) { const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
const k = currentKey(account);
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null; /** `[{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 * Make the account's key of `type` (ed25519, the default, or rsa); an
* one is replaced only with `replace` — and a key of the other type goes, so * existing one OF THAT TYPE is replaced only with `replace`. A key of the
* there is one key per account. * other type is left alone — a host refusing one type keeps the other.
*/ */
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) { async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa"); if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
if (sshKeyInfo(account) && !replace) { if (currentKeys(account).some((k) => k.type === type) && !replace) {
throw err("EEXISTS", "an SSH key already exists — replace it explicitly"); throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
} }
const label = const label =
String(comment || "agenthub") String(comment || "agenthub")
@@ -251,22 +266,26 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
const file = path.join(sshDir, KEY_FILES[type]); const file = path.join(sshDir, KEY_FILES[type]);
writeText(account, file, privateKey, { secret: true }); writeText(account, file, privateKey, { secret: true });
writeText(account, `${file}.pub`, publicKey, { secret: false }); writeText(account, `${file}.pub`, publicKey, { secret: false });
for (const [other, name] of Object.entries(KEY_FILES)) { // A member's home is AgentHub's to set up: their terminal git uses the key
if (other === type) continue; // too, unless they wrote a core.sshCommand of their own. The host's is
const f = path.join(sshDir, name); // its admin's choice (the Settings switch).
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) { if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
removeFile(account, f); return sshKeysInfo(account).find((k) => k.type === type) ?? null;
removeFile(account, `${f}.pub`);
}
}
return sshKeyInfo(account);
} }
function removeSshKey(account) { /**
const k = currentKey(account); * Delete the account's key of `type`, or every key when `type` is omitted;
if (!k) return false; * false when there was none. The terminal sshCommand goes with the last key.
removeFile(account, k.file); */
removeFile(account, `${k.file}.pub`); 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; return true;
} }
@@ -386,6 +405,69 @@ async function setAuthor(account, { name, email }) {
return getAuthor(account); 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);
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ───────── // ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/** /**
@@ -409,17 +491,18 @@ async function sshConfigFor(dir) {
(r) => r.stdout.trim(), (r) => r.stdout.trim(),
() => "" () => ""
); );
if (own) return [];
let account; let account;
try { try {
account = accountForDir(dir); account = accountForDir(dir);
} catch { } catch {
return []; 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 [ return [
"-c", "-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 +609,20 @@ async function cloneRepo({ url, parent, name, once = null }) {
if (remote.scheme === "ssh") { if (remote.scheme === "ssh") {
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key"); if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
const key = keyFile(account); const keys = keyFiles(account);
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh"; const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact // BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails. // records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`); 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") { } else if (account.kind === "member") {
const k = hostKey("https", remote.host); const k = hostKey("https", remote.host);
const before = readStore(account, { strict: true }); const before = readStore(account, { strict: true });
@@ -617,8 +708,12 @@ module.exports = {
makeRsaKeyPair, makeRsaKeyPair,
fingerprint, fingerprint,
sshKeyInfo, sshKeyInfo,
sshKeysInfo,
generateSshKey, generateSshKey,
removeSshKey, removeSshKey,
terminalSshCommand,
terminalSshState,
setTerminalSsh,
listHttps, listHttps,
saveHttps, saveHttps,
removeHttps, removeHttps,
+27 -4
View File
@@ -268,7 +268,7 @@ const paths = {
responses: { responses: {
200: { 200: {
description: 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": { "/api/me/git/ssh-key": {
post: { post: {
tags: ["Git identity"], 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", operationId: "generateSshKey",
requestBody: json({ replace: { type: "boolean" } }), requestBody: json({
replace: { type: "boolean" },
type: { type: "string", enum: ["ed25519", "rsa"] },
}),
responses: { 201: { description: "The identity." } }, responses: { 201: { description: "The identity." } },
}, },
delete: { delete: {
tags: ["Git identity"], tags: ["Git identity"],
summary: "Delete the SSH key", summary: "Delete the SSH key of ?type=ed25519|rsa, or every key",
operationId: "deleteSshKey", operationId: "deleteSshKey",
parameters: [
{
name: "type",
in: "query",
required: false,
schema: { type: "string", enum: ["ed25519", "rsa"] },
},
],
responses: { 200: { description: "The identity." } }, 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": { "/api/me/git/https": {
post: { post: {
tags: ["Git identity"], tags: ["Git identity"],
+14
View File
@@ -585,6 +585,12 @@ const schemas = {
required: ["ok"], required: ["ok"],
properties: { properties: {
ok: { type: "boolean", enum: [true], example: true }, 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).", description: "Run id (RunHandle.id).",
example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", 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: { responses: {
200: { 200: {
+29 -4
View File
@@ -1,8 +1,9 @@
/** /**
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`), * @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 — * 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`), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail * `user.email` of the account's global git config — a member's commits fail
* without one). The account is the signed-in user's * without one). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local * own Linux account under S3, else the host's, which only an admin (or local
@@ -27,6 +28,7 @@ const STATUS = {
EBADAUTHOR: 400, EBADAUTHOR: 400,
ESTOREUNREADABLE: 409, ESTOREUNREADABLE: 409,
EBADKEYTYPE: 400, EBADKEYTYPE: 400,
EFOREIGNSSH: 409,
}; };
function fail(res, e) { function fail(res, e) {
@@ -36,10 +38,15 @@ function fail(res, e) {
} }
async function view(account) { async function view(account) {
const keys = identity.sshKeysInfo(account);
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
return { return {
account: account.kind, account: account.kind,
writable: account.kind === "member" || account.writable !== false, 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,
https: identity.listHttps(account), https: identity.listHttps(account),
author: await identity.getAuthor(account), author: await identity.getAuthor(account),
}; };
@@ -81,7 +88,8 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
router.delete("/ssh-key", sameOriginGuard, async (req, res) => { router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
try { try {
const account = accountOf(req, { write: true }); 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" } }); return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
} }
res.json(await view(account)); res.json(await view(account));
@@ -90,6 +98,23 @@ 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);
}
});
router.post("/https", sameOriginGuard, async (req, res) => { router.post("/https", sameOriginGuard, async (req, res) => {
try { try {
const account = accountOf(req, { write: true }); const account = accountOf(req, { write: true });
+22
View File
@@ -626,9 +626,31 @@ router.delete("/:id", (req, res) => {
if (!ok) { if (!ok) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } }); 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 }); 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; module.exports = router;
// Shared with routes/lanes.js: lane actions spawn processes through the same // 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. // run-spawner, so they must sit behind the same loopback/same-origin check.