Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 | |||
| 916505fbac | |||
| 75a4693f27 | |||
| cd6a1d36ff | |||
| 2342f54179 |
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
|
||||
* admin's lists no other user's), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.list({ awaiting: true, limit: 20, mine: true })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
|
||||
@@ -200,6 +200,20 @@ describe("Sagi widget", () => {
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
|
||||
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
|
||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
|
||||
* (`mine=1` seed, and frames marked `mine: false` skipped).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.list({ status: "active", limit: 100, mine: true })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Each user's pet tells only of their own sessions: an admin/analyst
|
||||
// gets every user's, marked `mine: false` when not theirs.
|
||||
if (msg.mine === false) return;
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
|
||||
* OWN sessions even for an admin —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||
* user's OWN spend even for an admin — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
@@ -388,11 +389,11 @@ export function StatusBar({
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.list({ status: "active", limit: 1, mine: true })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.list({ awaiting: true, limit: 1, mine: true })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
|
||||
);
|
||||
await screen.findByText("1 need you");
|
||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
|
||||
// The user's OWN sessions, even for an admin who sees every user's rows.
|
||||
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
|
||||
expect(p).toMatchObject({ mine: true });
|
||||
await user.click(conn);
|
||||
expect(onConnectionClick).toHaveBeenCalled();
|
||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
|
||||
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
|
||||
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
@@ -38,14 +40,17 @@ import {
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const USERS_POLL_MS = 60000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
||||
// is admin/analyst only, so a developer gets no card.
|
||||
const { seesAll, local } = usePermissions();
|
||||
const { t: tu } = useTranslation("users");
|
||||
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
||||
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
|
||||
useEffect(() => {
|
||||
if (!seesAll || local || !hasPeople) return;
|
||||
const load = () =>
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
|
||||
.catch(() => {});
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, USERS_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [seesAll, local, hasPeople]);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const userTip = (g: HostUsageGroup) => {
|
||||
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||
// No account row (a developer, or not read yet): no card at all.
|
||||
if (!u) return "";
|
||||
const lines = [groupName(g)];
|
||||
{
|
||||
lines.push(
|
||||
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
||||
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
||||
);
|
||||
if (u.today)
|
||||
lines.push(
|
||||
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
|
||||
);
|
||||
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
|
||||
lines.push(
|
||||
t("host.userTipLastLogin", {
|
||||
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
|
||||
})
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
{/* The hover card sits on the name row only: over the whole
|
||||
tile it stacked on the CPU bar's own help tip. */}
|
||||
<Tip raw={userTip(g)} block>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click; on
|
||||
* hover, for staff, their role and $ spent today);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -69,8 +70,10 @@ const stats: HostStats = {
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null, list: vi.fn() },
|
||||
}));
|
||||
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
@@ -105,6 +108,7 @@ const full: HostStats = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the role and today's spend when a user tile is hovered", async () => {
|
||||
Object.assign(perms, { local: false });
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 2,
|
||||
sessions: 5,
|
||||
today: { cost: 1.5, tokens: 12000 },
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
lastLoginAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
// The card exists only once the users list has arrived.
|
||||
const nameRow = () => within(tile).getByRole("button").parentElement!;
|
||||
const tip = await vi.waitFor(() => {
|
||||
fireEvent.mouseEnter(nameRow());
|
||||
return screen.getByText(/Role: Developer/);
|
||||
});
|
||||
expect(tip.textContent).not.toContain("Login");
|
||||
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
||||
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
|
||||
// Only the name row carries it: the CPU bar keeps its own help tip alone.
|
||||
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
|
||||
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
|
||||
expect(screen.queryByText(/Role: Developer/)).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a developer no hover card, without reading the users list", async () => {
|
||||
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||
expect(api.users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
|
||||
@@ -86,7 +86,7 @@ import {
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
fontWeightBold: 700,
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
// xterm's own scrollback scrolls at the same share as the reports below.
|
||||
scrollSensitivity: SCROLL_SPEED,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
|
||||
const ws = wsRef.current;
|
||||
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
|
||||
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
|
||||
const col = clamp(
|
||||
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
|
||||
term.cols
|
||||
);
|
||||
const row = clamp(
|
||||
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
|
||||
term.rows
|
||||
);
|
||||
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||
};
|
||||
|
||||
@@ -329,7 +329,8 @@ describe("TerminalView", () => {
|
||||
|
||||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||
const screenBox = () => {
|
||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
|
||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
|
||||
.value as { element: HTMLElement };
|
||||
const screen = document.createElement("div");
|
||||
screen.className = "xterm-screen";
|
||||
screen.getBoundingClientRect = () =>
|
||||
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
|
||||
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
|
||||
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
|
||||
// two reports, sent as they add up.
|
||||
ws.sent.length = 0;
|
||||
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
for (let i = 0; i < 6; i++)
|
||||
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||
});
|
||||
|
||||
@@ -523,9 +526,7 @@ describe("TerminalView", () => {
|
||||
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||
// (xterm 6 removed the canvas one).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||
true
|
||||
);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. **Commit signing**
|
||||
* (under the author): one picker signs every commit with one of the keys
|
||||
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
|
||||
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
|
||||
* default, signs with it in the same step. The private key and
|
||||
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
|
||||
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
|
||||
* sign with Delete, a way out of the old account-wide setting and of a
|
||||
* signing setup the user made themselves, and **Import a key you have** takes
|
||||
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -233,9 +235,13 @@ function GitHostForm({
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit signing: the key every commit is signed with (one picker — choosing
|
||||
* a key turns signing on, "Don't sign" off) and Import a key the user already
|
||||
* has, which signs with it in the same step unless unticked.
|
||||
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
|
||||
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
|
||||
* workspaces that need it sign), the keys that can sign, each with Delete,
|
||||
* and Import a key the user already has. The old account-wide setting
|
||||
* (every repo signed) only shows when it is on, with a button that turns it
|
||||
* off; a signing setup the user made themselves in `~/.gitconfig` can be
|
||||
* taken out (their key file stays).
|
||||
*/
|
||||
function SigningCard({
|
||||
id,
|
||||
@@ -251,51 +257,90 @@ function SigningCard({
|
||||
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const auth = useAuth().state;
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
|
||||
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
|
||||
const laneSigning = id.laneSigning ?? [];
|
||||
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
|
||||
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
|
||||
const choose = async (keyId: string | null) => {
|
||||
if (keyId && signing.state === "foreign") {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signReplaceConfirm", {
|
||||
path: signing.path,
|
||||
key: keyLabel(keyId),
|
||||
}),
|
||||
confirmLabel: t("gitIdentity.signReplace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => live && setLanes(r.lanes))
|
||||
.catch(() => live && setLanes([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
|
||||
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
|
||||
const laneName = (l: Lane) => l.title || l.cwd;
|
||||
// Still signing, but its workspace is gone from the list (removed): the
|
||||
// folder keeps signing until it is turned off here.
|
||||
const orphans =
|
||||
lanes === null
|
||||
? []
|
||||
: laneSigning
|
||||
.filter((x) => !lanes.some((l) => l.id === x.laneId))
|
||||
.map(
|
||||
(x) =>
|
||||
({
|
||||
id: x.laneId,
|
||||
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
|
||||
cwd: "",
|
||||
}) as Lane
|
||||
);
|
||||
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
|
||||
|
||||
const chooseLane = async (lane: Lane, keyId: string | null) =>
|
||||
run(
|
||||
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
|
||||
keyId
|
||||
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
|
||||
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
|
||||
);
|
||||
|
||||
const clearForeign = async () => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
|
||||
confirmLabel: t("gitIdentity.signForeignRemove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
|
||||
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
|
||||
() => api.gitIdentity.setSigning(null, false, true),
|
||||
t("gitIdentity.signForeignRemoved")
|
||||
);
|
||||
};
|
||||
|
||||
const deleteKey = async (k: Key) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.removeKeyById(k.id!),
|
||||
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
|
||||
);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!form) return;
|
||||
let imported: string | null = null;
|
||||
let foreign = false;
|
||||
const ok = await run(
|
||||
async () => {
|
||||
const r = await api.gitIdentity.importKey({
|
||||
() =>
|
||||
api.gitIdentity.importKey({
|
||||
privateKey: form.text,
|
||||
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
sign: form.sign,
|
||||
});
|
||||
imported = r.imported?.id ?? null;
|
||||
foreign = r.signError?.code === "EFOREIGNSIGN";
|
||||
return r;
|
||||
},
|
||||
}),
|
||||
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||
);
|
||||
if (!ok) return;
|
||||
setForm(null);
|
||||
// Saved, but the user's own signing setup was left alone: ask once.
|
||||
if (imported && foreign) await choose(imported);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
@@ -306,6 +351,8 @@ function SigningCard({
|
||||
setForm({ ...form, text });
|
||||
};
|
||||
|
||||
const usable = keys.filter((k) => k.id);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
@@ -321,7 +368,7 @@ function SigningCard({
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setForm({ text: "", name: "", sign: true })}
|
||||
onClick={() => setForm({ text: "", name: "" })}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importKey")}
|
||||
@@ -330,52 +377,138 @@ function SigningCard({
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
data-testid="git-signing-state"
|
||||
className={`min-w-0 flex-1 text-[11px] ${
|
||||
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
|
||||
}`}
|
||||
{signing.state === "on" && (
|
||||
<div
|
||||
data-testid="git-signing-legacy"
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
|
||||
>
|
||||
{signing.state === "on"
|
||||
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
|
||||
: signing.state === "foreign"
|
||||
? t("gitIdentity.signForeign", { path: signing.path })
|
||||
: t("gitIdentity.signOff")}
|
||||
</span>
|
||||
{keys.length > 0 && (
|
||||
<select
|
||||
aria-label={t("gitIdentity.signKey")}
|
||||
value={signing.state === "on" ? (signing.key ?? "") : ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void choose(e.target.value || null)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">
|
||||
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
|
||||
</option>
|
||||
{keys
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
|
||||
}
|
||||
>
|
||||
{t("gitIdentity.signLegacyOff")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signing.state === "foreign" && (
|
||||
<div
|
||||
data-testid="git-signing-foreign"
|
||||
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("gitIdentity.signForeign", { path: signing.path })}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void clearForeign()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.signForeignRemove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1" data-testid="git-signing-lanes">
|
||||
<div className="text-[11px] font-medium text-fg-secondary">
|
||||
{t("gitIdentity.signLanes")}
|
||||
</div>
|
||||
{usable.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
|
||||
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
|
||||
) : (
|
||||
[...mine, ...orphans].map((l) => {
|
||||
const on = signedWith(l.id);
|
||||
return (
|
||||
<div key={l.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-xs ${
|
||||
on ? "text-status-success" : "text-fg-primary"
|
||||
}`}
|
||||
title={l.cwd || undefined}
|
||||
>
|
||||
{laneName(l)}
|
||||
</span>
|
||||
<select
|
||||
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
|
||||
value={on?.key ?? ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void chooseLane(l, e.target.value || null)}
|
||||
className="select !w-48 !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.signNone")}</option>
|
||||
{usable.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<Step n={1}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{usable.length > 0 && (
|
||||
<div className="space-y-1" data-testid="git-signing-keys">
|
||||
<div className="text-[11px] font-medium text-fg-secondary">
|
||||
{t("gitIdentity.signKeys")}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
|
||||
</Step>
|
||||
{usable.map((k) => {
|
||||
const used = laneSigning.filter((x) => x.key === k.id).length;
|
||||
return (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex items-center gap-2 rounded border border-border px-2 py-1"
|
||||
>
|
||||
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
|
||||
title={t("gitIdentity.signHostStep")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
|
||||
onClick={() => void deleteKey(k)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
@@ -407,24 +540,14 @@ function SigningCard({
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
aria-label={t("gitIdentity.importName")}
|
||||
placeholder={t("gitIdentity.importName")}
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
className={`${INPUT} !w-40 font-mono`}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.sign}
|
||||
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
|
||||
/>
|
||||
{t("gitIdentity.importSign")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
aria-label={t("gitIdentity.importName")}
|
||||
placeholder={t("gitIdentity.importName")}
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
className={`${INPUT} !w-40 font-mono`}
|
||||
/>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host;
|
||||
* commit signing: a key picked signs commits, an imported key signs in the
|
||||
* same step, and taking over the user's own signing key asks first.
|
||||
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
|
||||
* workspace's picker signs that workspace only, a signing key is deleted from
|
||||
* the card after a confirm, and the old account-wide setting and a setup the
|
||||
* user made themselves each have a way out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -35,6 +37,16 @@ vi.mock("../../../lib/api", () => ({
|
||||
removeHttps: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
setSigning: vi.fn(),
|
||||
setLaneSigning: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
list: vi.fn(async () => ({
|
||||
lanes: [
|
||||
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
|
||||
{ id: 2, title: "", cwd: "/w/docs" },
|
||||
],
|
||||
counts: {},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
const OFF = { state: "off" as const, key: null, path: null };
|
||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
account: "member",
|
||||
@@ -340,7 +353,7 @@ describe("GitIdentitySettings", () => {
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("signs commits with an imported key in one step", async () => {
|
||||
it("imports a key without signing anything by itself", async () => {
|
||||
const signingKey = {
|
||||
...KEY,
|
||||
id: "id_ed25519_signing",
|
||||
@@ -348,17 +361,15 @@ describe("GitIdentitySettings", () => {
|
||||
name: "signing",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||
...view({
|
||||
sshKeys: [signingKey],
|
||||
signing: { state: "on", key: "id_ed25519_signing", path: null },
|
||||
}),
|
||||
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
|
||||
imported: signingKey,
|
||||
signError: null,
|
||||
});
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
|
||||
const form = screen.getByRole("form", { name: "Import an existing private key" });
|
||||
await userEvent.type(
|
||||
@@ -368,26 +379,103 @@ describe("GitIdentitySettings", () => {
|
||||
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||
sign: true,
|
||||
});
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
// Now there is a key: each workspace gets its own picker, all "Don't sign".
|
||||
expect(
|
||||
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
|
||||
).toHaveValue("");
|
||||
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks before taking over a signing key the user set up themselves", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
it("signs one workspace only, and stops it", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
|
||||
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
vi.mocked(api.gitIdentity.setLaneSigning)
|
||||
.mockResolvedValueOnce(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
|
||||
)
|
||||
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
|
||||
await userEvent.selectOptions(pick, k.id);
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
|
||||
);
|
||||
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||
expect(
|
||||
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
|
||||
).toBeTruthy();
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
|
||||
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists a removed workspace that still signs, so it can be turned off", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
const pick = await screen.findByLabelText(
|
||||
"Signing key for Workspace #9 (removed)",
|
||||
{},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(pick).toHaveValue(k.id);
|
||||
await userEvent.selectOptions(pick, "");
|
||||
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
|
||||
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a signing key from the signing card, after asking", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [k],
|
||||
signing: { state: "off", key: null, path: null },
|
||||
laneSigning: [{ laneId: 1, key: k.id }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: OFF, laneSigning: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
|
||||
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
|
||||
);
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
|
||||
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
|
||||
});
|
||||
|
||||
it("turns off the old account-wide signing and removes a setup the user made", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const legacy = await screen.findByTestId("git-signing-legacy");
|
||||
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
|
||||
const foreign = await screen.findByTestId("git-signing-foreign");
|
||||
expect(foreign).toHaveTextContent("~/.ssh/mine");
|
||||
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
|
||||
);
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Users · CPU / RAM now",
|
||||
"usersCount": "{{n}} users"
|
||||
"usersCount": "{{n}} users",
|
||||
"userTipRole": "Role: {{v}}",
|
||||
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
||||
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
||||
"userTipLastLogin": "Last sign-in: {{v}}",
|
||||
"userTipNever": "never",
|
||||
"userTipDisabled": "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,23 +884,35 @@
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||
"signTitle": "Commit signing",
|
||||
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
|
||||
"signOff": "Commits are not signed.",
|
||||
"signOn": "Every commit is signed with {{key}}.",
|
||||
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
|
||||
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
|
||||
"signLanes": "Workspaces",
|
||||
"signLanesEmpty": "You have no workspace yet.",
|
||||
"signNeedKey": "Import a key you have, or make one below, to sign with.",
|
||||
"signLaneGone": "Workspace #{{id}} (removed)",
|
||||
"signLaneKey": "Signing key for {{name}}",
|
||||
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
|
||||
"signLaneStopped": "{{name}} no longer signs its commits.",
|
||||
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
|
||||
"signLegacyOff": "Sign per workspace only",
|
||||
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
|
||||
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
|
||||
"signForeignRemove": "Remove",
|
||||
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
|
||||
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
|
||||
"signKeys": "Keys you can sign with",
|
||||
"signKeyUsed_one": "signs {{count}} workspace",
|
||||
"signKeyUsed_other": "signs {{count}} workspaces",
|
||||
"signKeyCopy": "Copy the public key of {{key}}",
|
||||
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
|
||||
"signKeyDeleted": "Key {{key}} deleted.",
|
||||
"signKey": "Signing key",
|
||||
"signNone": "Don't sign",
|
||||
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
|
||||
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
|
||||
"signReplace": "Switch",
|
||||
"signSaved": "Commits are now signed with {{key}}.",
|
||||
"signStopped": "Commits are no longer signed.",
|
||||
"importKey": "Import a key you have",
|
||||
"importTitle": "Import an existing private key",
|
||||
"importFile": "Choose the key file",
|
||||
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Name (optional)",
|
||||
"importSign": "Sign my commits with it",
|
||||
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
|
||||
"importSubmit": "Import",
|
||||
"imported": "Key {{name}} imported.",
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||
"usersCount": "{{n}} người dùng"
|
||||
"usersCount": "{{n}} người dùng",
|
||||
"userTipRole": "Vai trò: {{v}}",
|
||||
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
||||
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
||||
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
|
||||
"userTipNever": "chưa bao giờ",
|
||||
"userTipDisabled": "Đã vô hiệu hóa"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,23 +872,35 @@
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||
"signTitle": "Ký commit",
|
||||
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
|
||||
"signOff": "Commit chưa được ký.",
|
||||
"signOn": "Mọi commit đang được ký bằng {{key}}.",
|
||||
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
|
||||
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
|
||||
"signLanes": "Workspace",
|
||||
"signLanesEmpty": "Bạn chưa có workspace nào.",
|
||||
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
|
||||
"signLaneGone": "Workspace #{{id}} (đã xóa)",
|
||||
"signLaneKey": "Key ký cho {{name}}",
|
||||
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
|
||||
"signLaneStopped": "{{name}} không còn ký commit.",
|
||||
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
|
||||
"signLegacyOff": "Chỉ ký theo workspace",
|
||||
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
|
||||
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
|
||||
"signForeignRemove": "Gỡ",
|
||||
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
|
||||
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
|
||||
"signKeys": "Key dùng để ký",
|
||||
"signKeyUsed_one": "ký {{count}} workspace",
|
||||
"signKeyUsed_other": "ký {{count}} workspace",
|
||||
"signKeyCopy": "Sao chép public key của {{key}}",
|
||||
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
|
||||
"signKeyDeleted": "Đã xóa key {{key}}.",
|
||||
"signKey": "Key dùng để ký",
|
||||
"signNone": "Không ký",
|
||||
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
|
||||
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
|
||||
"signReplace": "Chuyển",
|
||||
"signSaved": "Commit giờ được ký bằng {{key}}.",
|
||||
"signStopped": "Commit không còn được ký.",
|
||||
"importKey": "Nhập key có sẵn",
|
||||
"importTitle": "Nhập private key có sẵn",
|
||||
"importFile": "Chọn file key",
|
||||
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Tên (không bắt buộc)",
|
||||
"importSign": "Dùng key này để ký commit",
|
||||
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
|
||||
"importSubmit": "Nhập",
|
||||
"imported": "Đã nhập key {{name}}.",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
|
||||
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
|
||||
|
||||
const ROW = 20;
|
||||
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
||||
@@ -15,16 +15,17 @@ describe("wheelReports", () => {
|
||||
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
||||
const s: WheelState = { partial: 0 };
|
||||
let sent = 0;
|
||||
// 30 steps of 10px = 300px = 15 rows.
|
||||
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||
expect(sent).toBe(15 / LINES_PER_REPORT);
|
||||
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
|
||||
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
|
||||
});
|
||||
|
||||
it("drops the carried remainder when the swipe turns around", () => {
|
||||
const s: WheelState = { partial: 0 };
|
||||
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
||||
let sent = 0;
|
||||
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
|
||||
// 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
|
||||
for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
|
||||
expect(sent).toBe(-1);
|
||||
});
|
||||
|
||||
@@ -32,7 +33,16 @@ describe("wheelReports", () => {
|
||||
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
|
||||
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
|
||||
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
|
||||
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
|
||||
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
|
||||
});
|
||||
|
||||
it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
|
||||
const s: WheelState = { partial: 0 };
|
||||
let sent = 0;
|
||||
for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
|
||||
expect(sent).toBe(10 * SCROLL_SPEED);
|
||||
// Turning around starts from zero: the first notch up moves at once.
|
||||
expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
|
||||
});
|
||||
|
||||
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
|
||||
|
||||
+21
-3
@@ -1858,6 +1858,8 @@ export const api = {
|
||||
archived?: boolean;
|
||||
/** Pinned sessions first (by pin time), before the limit. */
|
||||
pinnedFirst?: boolean;
|
||||
/** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */
|
||||
mine?: boolean;
|
||||
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||
awaiting?: boolean;
|
||||
/** ISO bounds on `started_at`. */
|
||||
@@ -1878,6 +1880,7 @@ export const api = {
|
||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||
if (params?.awaiting) qs.set("awaiting", "1");
|
||||
if (params?.mine) qs.set("mine", "1");
|
||||
if (params?.from) qs.set("from", params.from);
|
||||
if (params?.to) qs.set("to", params.to);
|
||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||
@@ -3528,11 +3531,21 @@ export const api = {
|
||||
"/me/git/ssh-key/import",
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
/** PUT /api/me/git/signing — sign commits with key `key` (its id), or stop (null). */
|
||||
setSigning: (key: string | null, replace = false) =>
|
||||
/**
|
||||
* PUT /api/me/git/signing — the account-wide setting: sign every commit
|
||||
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
|
||||
* signing setup the user made themselves (their key file stays).
|
||||
*/
|
||||
setSigning: (key: string | null, replace = false, clearForeign = false) =>
|
||||
request<GitIdentity>("/me/git/signing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ key, replace }),
|
||||
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
|
||||
}),
|
||||
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
|
||||
setLaneSigning: (laneId: number, key: string | null) =>
|
||||
request<GitIdentity>("/me/git/signing/lane", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ laneId, key }),
|
||||
}),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
@@ -5450,6 +5463,11 @@ export interface GitIdentity {
|
||||
* from an older server.
|
||||
*/
|
||||
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
|
||||
/**
|
||||
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
|
||||
* `key` = the key's id, null when its file is gone. Absent from an older server.
|
||||
*/
|
||||
laneSigning?: { laneId: number; key: string | null }[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/**
|
||||
|
||||
@@ -1971,6 +1971,10 @@ export interface WSMessage {
|
||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||
* same instant the underlying event occurred). */
|
||||
timestamp: string;
|
||||
/** Server mode, an admin/analyst's session/agent/event frames only: whether
|
||||
* the session is their OWN (they also get every other user's). Absent for a
|
||||
* developer (who only ever gets their own) and in local mode. */
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
// ───── Session stats ─────
|
||||
|
||||
@@ -8,13 +8,19 @@
|
||||
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
|
||||
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
|
||||
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
|
||||
* finger, and a wheel notch always sends at least one.
|
||||
* finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
|
||||
* slows both down (the user asked for 30 % less): a touchpad's travel is
|
||||
* scaled, and a notch's reports are carried as a fraction, rounded, so the
|
||||
* first notch still moves and the average comes out at that share.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
||||
export const LINES_PER_REPORT = 5;
|
||||
|
||||
/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
|
||||
export const SCROLL_SPEED = 0.7;
|
||||
|
||||
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
|
||||
const NOTCH_PX = 50;
|
||||
|
||||
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
|
||||
export interface WheelState {
|
||||
/** Touchpad travel not yet sent, in rows (signed). */
|
||||
partial: number;
|
||||
/** Wheel-notch reports owed but not sent yet (signed, a fraction). */
|
||||
notch?: number;
|
||||
/** Direction of the last notch (the carry above may round to either sign). */
|
||||
notchDir?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,18 +52,25 @@ export function wheelReports(
|
||||
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
||||
// Touchpad: carry the remainder, so slow swipes still add up.
|
||||
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
||||
state.partial += ev.deltaY / rowPx;
|
||||
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
|
||||
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
||||
state.partial -= n * LINES_PER_REPORT;
|
||||
return n;
|
||||
}
|
||||
// A notch (or a fast flick): at least one report, more for a long one.
|
||||
state.partial = 0;
|
||||
if (state.notchDir !== sign) state.notch = 0; // turned around
|
||||
state.notchDir = sign;
|
||||
const lines =
|
||||
ev.deltaMode === DOM_DELTA_LINE
|
||||
? ev.deltaY
|
||||
: ev.deltaMode === DOM_DELTA_PAGE
|
||||
? ev.deltaY * rows
|
||||
: ev.deltaY / rowPx;
|
||||
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
|
||||
const owed =
|
||||
(state.notch ?? 0) +
|
||||
sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
|
||||
const n = sign * Math.round(Math.abs(owed));
|
||||
state.notch = owed - n;
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.62 (rebuild client to match)
|
||||
UI build v2.8.67 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -1563,7 +1563,8 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
|
||||
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
|
||||
POST /api/me/git/ssh-key/import Import a private key the user already has — Body: { privateKey, name?, sign?, replace? } — OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) or PEM (PKCS#1/PKCS#8) text, ed25519 or RSA ≥ 2048; saved as a NAMED key (`name`, else `signing`, `signing-2`…) written like a generated one, so it serves clones too; only `imported` (its public half) comes back. 400 EBADKEY (not a key, a `.pub`, halves that do not match), EKEYPASSPHRASE (an encrypted key — a dashboard commit has nowhere to type the passphrase), EBADKEYTYPE; 409 EEXISTS (the same key already imported, or the name taken), ETOOMANYKEYS. With `sign: true` commits are signed with it at once; a refusal of that step comes back as `signError` (the key stays saved) → 201
|
||||
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign)
|
||||
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign) — the old ACCOUNT-WIDE setting; `clearForeign: true` (with `key: null`) also takes out a signing setup the user made themselves, never their key file
|
||||
PUT /api/me/git/signing/lane Sign ONE workspace's commits — Body: { laneId, key: <key id> | null } — a file of ours `lane-<id>.gitconfig` (gpg.format ssh, user.signingkey, commit.gpgsign) included from `~/.gitconfig` by `includeIf "gitdir:<the workspace's git dir>"` and `"gitdir:<common dir>/worktrees/ah-<id>-*"` (its sessions' worktrees), so only that workspace signs — terminal, Changes panel and agents alike — and nothing is written into the repository. 404 for a workspace the caller cannot see, 403 EFOREIGN when its folder runs as another account, 409 ENOREPO when it is not a git repository. `GET` answers `laneSigning: [{ laneId, key }]`; deleting a key stops every workspace signing with it. Two workspaces on one folder share it; a workspace in a SUBFOLDER of a repo signs the whole repo
|
||||
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh://<alias>/` rewrite. 400 EBADHOST / EBADSSHUSER
|
||||
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
@@ -2175,6 +2176,7 @@ Returns all sessions, ordered by most recent activity.
|
||||
| `offset` | integer | 0 | Pagination offset |
|
||||
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
|
||||
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
|
||||
| `mine` | `1` | - | Server mode: only the caller's OWN sessions (workspaces they own, or their Linux account's), even for an admin or analyst. The pet seeds its count from it. No-op in local mode |
|
||||
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
|
||||
| `cwd` | string | - | Exact working directory |
|
||||
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
|
||||
@@ -3717,6 +3719,8 @@ stateDiagram-v2
|
||||
|
||||
Server broadcasts JSON messages to all connected clients:
|
||||
|
||||
Server mode: a developer gets only the frames of their own sessions and workspaces. An admin or analyst gets every user's, and each `session_created`, `session_updated`, `agent_created`, `agent_updated` and `new_event` frame they receive also carries `"mine": true|false`, which says whether the session is their own (the rule of `GET /api/sessions?mine=1`). The pet counts only frames with `mine` absent or true. Local mode never adds `mine`.
|
||||
|
||||
#### session.created
|
||||
|
||||
Sent when a new session is created.
|
||||
|
||||
+3
-1
@@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU. Each user's live
|
||||
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
||||
user, their busiest processes on click; their home share stays in
|
||||
user, their busiest processes on click; on hover, for an
|
||||
admin or analyst — from `GET /api/users` — their role, $ spent today,
|
||||
workspaces, sessions and last sign-in; their home share stays in
|
||||
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||
processes by their kernel name (never argv or env), and their home's size
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.62",
|
||||
"version": "2.8.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.62",
|
||||
"version": "2.8.67",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.62",
|
||||
"version": "2.8.67",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* the same public half `ssh-keygen -y` prints, only that half returned, the
|
||||
* private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key
|
||||
* twice refused; a real commit signed and verified by git; a signing key the
|
||||
* user set themselves kept unless replaced; deleting the key stops signing.
|
||||
* user set themselves kept unless replaced, and taken out (its file kept) on
|
||||
* `clearForeign`; deleting the key stops signing. Per workspace
|
||||
* (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its
|
||||
* sessions' `ah-<id>-*` worktrees — are signed, never another repo's, the
|
||||
* user's own includes stay, and deleting the key stops that workspace's too.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -204,3 +208,127 @@ describe("PUT /api/me/git/signing", () => {
|
||||
await removeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/me/git/signing/lane", () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "t",
|
||||
GIT_AUTHOR_EMAIL: "me@x",
|
||||
GIT_COMMITTER_NAME: "t",
|
||||
GIT_COMMITTER_EMAIL: "me@x",
|
||||
};
|
||||
const commit = (dir, msg) =>
|
||||
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env });
|
||||
/** `G` signed and verified, `N` not signed. */
|
||||
const sig = (dir, allowed) =>
|
||||
execFileSync(
|
||||
"git",
|
||||
["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
const lane = async (dir) => {
|
||||
const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true });
|
||||
return r.body.lane.id;
|
||||
};
|
||||
|
||||
it("signs one workspace and its sessions' worktrees, never another repo", async () => {
|
||||
const A = path.join(ROOT, "ws-a");
|
||||
const B = path.join(ROOT, "ws-b");
|
||||
const plain = path.join(ROOT, "ws-plain");
|
||||
for (const d of [A, B]) {
|
||||
execFileSync("git", ["init", "-q", d]);
|
||||
commit(d, "first");
|
||||
}
|
||||
fs.mkdirSync(plain);
|
||||
const [a, b, p] = [await lane(A), await lane(B), await lane(plain)];
|
||||
// A user's own include stays whatever we do.
|
||||
execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]);
|
||||
const k = keygen("lane", ["-t", "ed25519"]);
|
||||
const imp = await request("POST", "/api/me/git/ssh-key/import", {
|
||||
privateKey: k.text,
|
||||
name: "lane",
|
||||
});
|
||||
const key = imp.body.imported.id;
|
||||
const allowed = path.join(ROOT, "allowed-lane");
|
||||
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
|
||||
|
||||
const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
|
||||
assert.equal(on.status, 200);
|
||||
assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]);
|
||||
// Nothing account-wide: the global settings stay unset.
|
||||
assert.equal(on.body.signing.state, "off");
|
||||
assert.equal(gitGet("commit.gpgsign"), "");
|
||||
commit(A, "in a");
|
||||
commit(B, "in b");
|
||||
assert.equal(sig(A, allowed), "G");
|
||||
assert.equal(sig(B, allowed), "N");
|
||||
// A session's worktree of A is signed; any other worktree of A is not.
|
||||
const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`);
|
||||
const other = path.join(ROOT, "other-wt");
|
||||
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]);
|
||||
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]);
|
||||
commit(wt, "in session worktree");
|
||||
commit(other, "in other worktree");
|
||||
assert.equal(sig(wt, allowed), "G");
|
||||
assert.equal(sig(other, allowed), "N");
|
||||
// Nothing was written into the repository itself.
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/);
|
||||
|
||||
// Not a repo: refused; an unknown workspace: 404.
|
||||
const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key });
|
||||
assert.equal(notRepo.status, 409);
|
||||
assert.equal(notRepo.body.error.code, "ENOREPO");
|
||||
const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key });
|
||||
assert.equal(gone.status, 404);
|
||||
|
||||
// Off: unsigned again, our file gone, the user's include kept.
|
||||
const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null });
|
||||
assert.deepEqual(off.body.laneSigning, []);
|
||||
commit(A, "after off");
|
||||
assert.equal(sig(A, allowed), "N");
|
||||
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
|
||||
|
||||
// A workspace removed from the dashboard while it signed can still be turned off.
|
||||
await request("PUT", "/api/me/git/signing/lane", { laneId: b, key });
|
||||
await request("POST", `/api/lanes/${b}/remove`, {});
|
||||
const orphan = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]);
|
||||
const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null });
|
||||
assert.equal(stopGone.status, 200);
|
||||
assert.deepEqual(stopGone.body.laneSigning, []);
|
||||
const b2 = await lane(B);
|
||||
|
||||
// Deleting the key stops every workspace that signs with it.
|
||||
await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
|
||||
await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key });
|
||||
const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`);
|
||||
assert.equal(del.status, 200);
|
||||
assert.deepEqual(del.body.laneSigning, []);
|
||||
commit(A, "after the key went");
|
||||
assert.equal(sig(A, allowed), "N");
|
||||
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
|
||||
assert.deepEqual(
|
||||
fs
|
||||
.readdirSync(path.join(ROOT, ".claude", "agenthub-signing"))
|
||||
.filter((n) => n.endsWith(".gitconfig")),
|
||||
[]
|
||||
);
|
||||
execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]);
|
||||
await removeAll();
|
||||
});
|
||||
|
||||
it("takes out a signing setup the user made themselves, keeping their key file", async () => {
|
||||
const mine = path.join(ROOT, "my-own-key");
|
||||
fs.writeFileSync(mine, "not ours");
|
||||
execFileSync("git", ["config", "--global", "user.signingkey", mine]);
|
||||
execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]);
|
||||
const kept = await request("PUT", "/api/me/git/signing", { key: null });
|
||||
assert.equal(kept.body.signing.state, "foreign");
|
||||
const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.signing.state, "off");
|
||||
assert.equal(gitGet("user.signingkey"), "");
|
||||
assert.equal(gitGet("commit.gpgsign"), "");
|
||||
assert.equal(fs.readFileSync(mine, "utf8"), "not ours");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file websocket-mine.test.js
|
||||
* @description Server mode broadcasts (server/websocket.js `broadcast`): an
|
||||
* admin/analyst's session, agent and event frames say whether the session is
|
||||
* their OWN (`mine`), a developer's and local mode's never carry it, and
|
||||
* `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the
|
||||
* caller's own sessions whatever their role — the pet counts only those.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ws-mine-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const ws = require("../websocket");
|
||||
|
||||
const prevMode = process.env.AGENTHUB_MODE;
|
||||
before(() => {
|
||||
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
||||
lane.run("/w/boss", 99); // id 1, the admin's
|
||||
lane.run("/w/dev", 2); // id 2, a developer's
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
|
||||
);
|
||||
s.run("s-boss", "boss", 1);
|
||||
s.run("s-dev", "dev", 2);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
ws._setClientsForTest(null);
|
||||
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
|
||||
else process.env.AGENTHUB_MODE = prevMode;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function client(user) {
|
||||
const got = [];
|
||||
return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) };
|
||||
}
|
||||
|
||||
describe("broadcast `mine`", () => {
|
||||
it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
const boss = client({ id: 99, username: "boss", role: "admin" });
|
||||
const dev = client({ id: 2, username: "dev", role: "developer" });
|
||||
ws._setClientsForTest(new Set([boss, dev]));
|
||||
ws.broadcast("session_updated", { id: "s-boss", status: "active" });
|
||||
ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" });
|
||||
ws.broadcast("lane_update", { lane: { id: 2 } });
|
||||
assert.deepEqual(
|
||||
boss.got.map((m) => [m.type, m.mine]),
|
||||
[
|
||||
["session_updated", true],
|
||||
["new_event", false],
|
||||
["lane_update", undefined],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
dev.got.map((m) => [m.type, m.mine]),
|
||||
[
|
||||
["new_event", undefined],
|
||||
["lane_update", undefined],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("adds nothing in local mode", () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
const me = client(null);
|
||||
ws._setClientsForTest(new Set([me]));
|
||||
ws.broadcast("session_updated", { id: "s-dev" });
|
||||
assert.equal(me.got[0].mine, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions?mine=1", () => {
|
||||
it("lists only the caller's own sessions, an admin's too", async () => {
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: 99, username: "boss", role: "admin" };
|
||||
next();
|
||||
});
|
||||
app.use("/api/sessions", require("../routes/sessions"));
|
||||
const srv = app.listen(0);
|
||||
try {
|
||||
const base = `http://127.0.0.1:${srv.address().port}/api/sessions`;
|
||||
const ids = async (qs) =>
|
||||
(await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort();
|
||||
assert.deepEqual(await ids("?mine=1"), ["s-boss"]);
|
||||
assert.deepEqual(await ids(""), ["s-boss", "s-dev"]);
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1854,6 +1854,22 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
|
||||
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
|
||||
try {
|
||||
db.exec(`
|
||||
DROP TABLE IF EXISTS chat_files;
|
||||
DROP TABLE IF EXISTS chat_messages;
|
||||
DROP TABLE IF EXISTS chat_members;
|
||||
DROP TABLE IF EXISTS chat_rooms;
|
||||
`);
|
||||
if (!process.env.DASHBOARD_DB_PATH) {
|
||||
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
/* best effort: a leftover is unused */
|
||||
}
|
||||
|
||||
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||
// local mode. A user's notifications go only to their own subscriptions.
|
||||
try {
|
||||
|
||||
+146
-3
@@ -574,7 +574,7 @@ async function signingState(account) {
|
||||
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
|
||||
* the key's PATH crosses sudo's argv, never its content.
|
||||
*/
|
||||
async function setSigning(account, { id = null, replace = false } = {}) {
|
||||
async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) {
|
||||
const { git } = require("./worktree");
|
||||
const home = homeOf(account);
|
||||
const state = await signingState(account);
|
||||
@@ -583,7 +583,9 @@ async function setSigning(account, { id = null, replace = false } = {}) {
|
||||
/* already unset */
|
||||
});
|
||||
if (id == null) {
|
||||
if (state.state === "foreign") return state;
|
||||
// The user's own setup is left alone unless they asked to take it out
|
||||
// (`clearForeign`): only the three settings go, never their key file.
|
||||
if (state.state === "foreign" && !clearForeign) return state;
|
||||
if ((await globalGet(account, "user.signingkey")) !== "") {
|
||||
await unset("commit.gpgsign");
|
||||
await unset("user.signingkey");
|
||||
@@ -605,6 +607,138 @@ async function setSigning(account, { id = null, replace = false } = {}) {
|
||||
return signingState(account);
|
||||
}
|
||||
|
||||
// ── Commit signing per workspace (`includeIf "gitdir:…"`) ──────────────────
|
||||
//
|
||||
// A workspace is signed by a small config file of OURS per workspace
|
||||
// (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign),
|
||||
// pulled into the account's `~/.gitconfig` by `includeIf "gitdir:<dir>"` for
|
||||
// the workspace's own git dir and for the git dirs of the worktrees its
|
||||
// sessions make (`ah-<id>-*`, pty-run.js). Every git that reads the account's
|
||||
// config — the terminal, the Changes panel, an agent — then signs there and
|
||||
// nowhere else; nothing is written into the user's repository.
|
||||
|
||||
const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/;
|
||||
|
||||
/** Where the per-workspace files live: ours, beside the account's keys. */
|
||||
function signingDir(account) {
|
||||
return account.kind === "member"
|
||||
? path.join(paths(account).base, "signing")
|
||||
: path.join(getClaudeHome(), "agenthub-signing");
|
||||
}
|
||||
const laneSignFile = (account, laneId) =>
|
||||
path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`);
|
||||
|
||||
/** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */
|
||||
async function ourIncludes(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--null",
|
||||
"--get-regexp",
|
||||
"^includeif\\..*\\.path$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const dir = signingDir(account);
|
||||
const rows = [];
|
||||
// `--null`: "key\nvalue\0" — a git dir or a path may hold spaces.
|
||||
for (const rec of out.split("\0")) {
|
||||
const at = rec.indexOf("\n");
|
||||
if (at <= 0) continue;
|
||||
const key = rec.slice(0, at);
|
||||
const value = rec.slice(at + 1);
|
||||
if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) {
|
||||
rows.push({ key, value });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */
|
||||
async function dropIncludes(account, file) {
|
||||
const { git } = require("./worktree");
|
||||
for (const r of await ourIncludes(account)) {
|
||||
if (r.value !== file) continue;
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
r.key,
|
||||
r.value,
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** The key file a workspace file signs with (its `signingkey` line), or null. */
|
||||
function laneSignKeyFile(account, file) {
|
||||
const text = readText(account, file) || "";
|
||||
const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text);
|
||||
return m ? m[1].replace(/\\(["\\])/g, "$1") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which workspaces sign their commits, and with which key:
|
||||
* `[{laneId, key}]` (`key` null when the key file it names is gone).
|
||||
*/
|
||||
async function laneSigningList(account) {
|
||||
const keys = currentKeys(account);
|
||||
const seen = new Map();
|
||||
for (const r of await ourIncludes(account)) {
|
||||
const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]);
|
||||
if (seen.has(laneId)) continue;
|
||||
const keyFile = laneSignKeyFile(account, r.value);
|
||||
seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null);
|
||||
}
|
||||
return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId);
|
||||
}
|
||||
|
||||
const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`;
|
||||
|
||||
/**
|
||||
* Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop
|
||||
* (`id` null). The git dirs come from git itself, run in the workspace's
|
||||
* folder as its account — never from the request. ENOREPO when the folder is
|
||||
* not a git repository.
|
||||
*/
|
||||
async function setLaneSigning(account, { laneId, cwd, id = null }) {
|
||||
const n = Number(laneId);
|
||||
if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace");
|
||||
const file = laneSignFile(account, n);
|
||||
await dropIncludes(account, file);
|
||||
if (id == null) {
|
||||
removeFile(account, file);
|
||||
return;
|
||||
}
|
||||
const k = currentKeys(account).find((x) => x.id === id);
|
||||
if (!k) throw err("ENOKEY", "no such SSH key");
|
||||
const { git } = require("./worktree");
|
||||
const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim());
|
||||
let gitDir;
|
||||
let commonDir;
|
||||
try {
|
||||
gitDir = await rev(["--absolute-git-dir"]);
|
||||
commonDir = await rev(["--path-format=absolute", "--git-common-dir"]);
|
||||
} catch {
|
||||
throw err("ENOREPO", "this workspace's folder is not a git repository");
|
||||
}
|
||||
writeText(
|
||||
account,
|
||||
file,
|
||||
`# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` +
|
||||
`[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` +
|
||||
`[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`,
|
||||
{ secret: false }
|
||||
);
|
||||
const add = (pattern) =>
|
||||
git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]);
|
||||
await add(gitDir);
|
||||
// The worktrees this workspace's sessions make (`claude --worktree=ah-<id>-…`).
|
||||
await add(path.join(commonDir, "worktrees", `ah-${n}-*`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||
* neither is given; false when there was none. A login naming a deleted key
|
||||
@@ -619,11 +753,18 @@ async function removeSshKey(account, which) {
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
// Commits signed with a key about to go would all fail: stop signing first.
|
||||
// Commits signed with a key about to go would all fail: stop signing first —
|
||||
// account-wide, and in every workspace that signs with it.
|
||||
const signingKey = await globalGet(account, "user.signingkey");
|
||||
if (signingKey && gone.some((k) => k.file === signingKey)) {
|
||||
await setSigning(account, { id: null }).catch(() => {});
|
||||
}
|
||||
const goneIds = new Set(gone.map((k) => k.id));
|
||||
for (const l of await laneSigningList(account)) {
|
||||
if (goneIds.has(l.key)) {
|
||||
await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {});
|
||||
}
|
||||
}
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
@@ -1425,6 +1566,8 @@ module.exports = {
|
||||
importSshKey,
|
||||
parsePrivateKey,
|
||||
signingState,
|
||||
laneSigningList,
|
||||
setLaneSigning,
|
||||
setSigning,
|
||||
removeSshKey,
|
||||
terminalSshCommand,
|
||||
|
||||
@@ -594,7 +594,13 @@ const paths = {
|
||||
summary:
|
||||
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
|
||||
operationId: "setCommitSigning",
|
||||
requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" } }),
|
||||
description:
|
||||
"The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.",
|
||||
requestBody: json({
|
||||
key: { type: "string", nullable: true },
|
||||
replace: { type: "boolean" },
|
||||
clearForeign: { type: "boolean" },
|
||||
}),
|
||||
responses: {
|
||||
200: { description: "The identity; `signing.state` is on, off or foreign." },
|
||||
400: { description: "ENOKEY" },
|
||||
@@ -602,6 +608,26 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/signing/lane": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)",
|
||||
description:
|
||||
"Writes a small file of AgentHub's (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:<the workspace's git dir>\"` and `gitdir:<common dir>/worktrees/ah-<id>-*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.",
|
||||
operationId: "setLaneCommitSigning",
|
||||
requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [
|
||||
"laneId",
|
||||
]),
|
||||
responses: {
|
||||
200: { description: "The identity, with `laneSigning`." },
|
||||
400: { description: "ENOKEY" },
|
||||
403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." },
|
||||
404: { description: "ENOTFOUND — no such workspace (or not the caller's)." },
|
||||
409: { description: "ENOREPO — the workspace's folder is not a git repository." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-terminal": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -154,6 +154,14 @@ const paths = {
|
||||
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
name: "mine",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
in: "query",
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* public half ever comes back), and whether commits are signed with one of
|
||||
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
|
||||
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
|
||||
* set themselves is replaced only on `replace: true`). The account is the signed-in user's
|
||||
* set themselves is replaced only on `replace: true`, or taken out on
|
||||
* `clearForeign: true` — their key file is never touched), or — per workspace —
|
||||
* which of the caller's workspaces sign (`PUT /signing/lane`, an
|
||||
* `includeIf "gitdir:…"` of one small file of ours each). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||
* request, and every write passes the same-origin guard.
|
||||
@@ -47,6 +50,9 @@ const STATUS = {
|
||||
EBADKEY: 400,
|
||||
EKEYPASSPHRASE: 400,
|
||||
EFOREIGNSIGN: 409,
|
||||
ENOREPO: 409,
|
||||
ENOTFOUND: 404,
|
||||
EFOREIGN: 403,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -70,6 +76,8 @@ async function view(account, username) {
|
||||
codecommitRegions: identity.CODECOMMIT_REGIONS,
|
||||
https: identity.listHttps(account),
|
||||
signing: await identity.signingState(account),
|
||||
// Workspaces whose commits are signed: `[{laneId, key}]`.
|
||||
laneSigning: await identity.laneSigningList(account),
|
||||
author: await identity.getAuthor(account),
|
||||
// What the author email form suggests while none is set.
|
||||
defaultEmail: identity.defaultEmail(username),
|
||||
@@ -148,7 +156,48 @@ router.put("/signing", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
|
||||
await identity.setSigning(account, { id, replace: req.body?.replace === true });
|
||||
await identity.setSigning(account, {
|
||||
id,
|
||||
replace: req.body?.replace === true,
|
||||
clearForeign: req.body?.clearForeign === true,
|
||||
});
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sign the commits of ONE workspace with `{laneId, key}` (`key: null` stops).
|
||||
* The workspace must be one the caller may see (scopeGuard, `laneId` in the
|
||||
* body) whose folder runs as this same account — a workspace another account
|
||||
* runs could never read this account's `~/.gitconfig` (EFOREIGN).
|
||||
*/
|
||||
router.put("/signing/lane", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const laneId = Number(req.body?.laneId);
|
||||
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
|
||||
// Stopping touches only this account's own file, so it needs no workspace:
|
||||
// one removed from the dashboard while it signed can still be turned off.
|
||||
if (id == null && Number.isSafeInteger(laneId) && laneId > 0) {
|
||||
await identity.setLaneSigning(account, { laneId, id: null });
|
||||
return res.json(await view(account, usernameOf(req)));
|
||||
}
|
||||
const lane = Number.isSafeInteger(laneId) ? require("../lib/lanes").getLane(laneId) : null;
|
||||
if (!lane || !require("../lib/scope").canSeeLane(req, laneId)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such workspace" } });
|
||||
}
|
||||
const folder = identity.accountForDir(lane.cwd);
|
||||
if (
|
||||
folder.kind !== account.kind ||
|
||||
(folder.kind === "member" && folder.acct.login !== account.acct.login)
|
||||
) {
|
||||
throw Object.assign(new Error("this workspace runs as another account"), {
|
||||
code: "EFOREIGN",
|
||||
});
|
||||
}
|
||||
await identity.setLaneSigning(account, { laneId, cwd: lane.cwd, id });
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
|
||||
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
// `mine=1` = the requesting user's OWN sessions whatever their role (the
|
||||
// pet's count): an admin/analyst otherwise sees every user's. "1" locally.
|
||||
if (req.query.mine === "1") {
|
||||
const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
|
||||
where.push(o.where);
|
||||
params.push(...o.params);
|
||||
}
|
||||
// `awaiting=1` = only sessions blocked on the user (an active session with
|
||||
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
||||
if (req.query.awaiting === "1") {
|
||||
|
||||
+39
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients.
|
||||
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au
|
||||
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
|
||||
|
||||
let wss = null;
|
||||
// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast).
|
||||
const MINE_TYPES = new Set([
|
||||
"session_created",
|
||||
"session_updated",
|
||||
"agent_created",
|
||||
"agent_updated",
|
||||
"new_event",
|
||||
]);
|
||||
// How often an open terminal's login is re-checked in server mode.
|
||||
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
|
||||
|
||||
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
|
||||
|
||||
function broadcast(type, data) {
|
||||
if (!wss) return;
|
||||
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
|
||||
const timestamp = new Date().toISOString();
|
||||
const message = JSON.stringify({ type, data, timestamp });
|
||||
// Server mode: worked out once, and only when a member is connected.
|
||||
let audience;
|
||||
// A message for exactly these users (a member's own config change): no
|
||||
@@ -255,6 +264,24 @@ function broadcast(type, data) {
|
||||
return audienceIncludes(audience, client.user);
|
||||
};
|
||||
let stripped;
|
||||
// Server mode: an admin/analyst gets every user's session news, so a
|
||||
// session-scoped frame tells them whether it is their OWN (`mine`) — the
|
||||
// pet counts only those. Worked out once per broadcast, on first need.
|
||||
const owned = MINE_TYPES.has(type) && isServerMode();
|
||||
let mineMsg;
|
||||
let otherMsg;
|
||||
const forStaff = (client) => {
|
||||
if (audience === undefined) {
|
||||
try {
|
||||
audience = broadcastAudience(type, data);
|
||||
} catch {
|
||||
audience = { staffOnly: true };
|
||||
}
|
||||
}
|
||||
const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id));
|
||||
if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true }));
|
||||
return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false }));
|
||||
};
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1 && reaches(client)) {
|
||||
try {
|
||||
@@ -264,6 +291,10 @@ function broadcast(type, data) {
|
||||
client.send(stripped);
|
||||
return;
|
||||
}
|
||||
if (owned && !only && client.user && !member) {
|
||||
client.send(forStaff(client));
|
||||
return;
|
||||
}
|
||||
client.send(message);
|
||||
} catch {
|
||||
// Client closed between readyState check and send — safe to ignore
|
||||
@@ -331,7 +362,13 @@ function closeWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */
|
||||
function _setClientsForTest(clients) {
|
||||
wss = clients ? { clients } : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
_setClientsForTest,
|
||||
initWebSocket,
|
||||
initPtyWebSocket,
|
||||
broadcast,
|
||||
|
||||
Reference in New Issue
Block a user