Compare commits

...

11 Commits

Author SHA1 Message Date
nntrivi2001 cd6a1d36ff chore: v2.8.63 — commit signing per workspace 2026-11-02 16:50:15 +07:00
nntrivi2001 2342f54179 feat(git-identity): commit signing per workspace, and Delete in the signing card
Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.

The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.

Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
2026-11-02 16:49:27 +07:00
nntrivi2001 246b554dab chore: v2.8.62 — every account sees other users' avatars 2026-11-02 16:40:37 +07:00
nntrivi2001 7ee79292fa docs(api): GET /api/users/:id/avatar is open to every signed-in account 2026-11-02 16:40:37 +07:00
nntrivi2001 600e8eec6a fix(avatars): every signed-in account sees other users' pictures
System Health shows every user's name and avatarAt to everyone, but the
picture route GET /api/users/:id/avatar sat under the staff-only /users
prefix, so a developer got 403 EADMIN and saw only initials. authGuard now
lets any signed-in account make that one read (EVERYONE_READS); every
other /users path stays staff-only, and the test checks both.
2026-11-02 16:40:29 +07:00
nntrivi2001 3ec1978176 chore: v2.8.61 — uploads survive a restart, SSH key import 2026-11-02 16:14:32 +07:00
nntrivi2001 12ea853170 feat(files): uploads survive a dashboard restart
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.

A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.

New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
2026-11-02 16:12:57 +07:00
nntrivi2001 7e41c81d53 feat(git-identity): import an existing SSH key and sign commits with it
Settings → Git gains Commit signing: one picker signs every commit
(terminal, Changes panel, agents) with one of the account's keys —
gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign
true in its own ~/.gitconfig; a signing key the user set up themselves is
replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key
you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048)
into ~/.config/agenthub/ssh/ as a named key, written like a generated one
(member: write-as, 0600), and by default signs with it in the same step.
A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key
twice are refused; only the public half ever comes back. Deleting the
signing key stops signing so commits keep working.

The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the
Gitea and Backlog steps, and the File Explorer's ESECRET toast now says
that top-level dot folders of a home are edited in a terminal, and where
the SSH keys live instead.
2026-11-02 16:12:57 +07:00
nntrivi2001 2d6c751fb2 fix(terminal): touchpad scrolls 1:1 with the finger
xterm.js sends one wheel report per event whatever its size and damps a
touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on,
TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one
per 5 rows of travel (what one report scrolls in a Claude pane), the
remainder carried between events, at least one per wheel notch, at the
pointer's cell with xterm's modifier bits.
2026-11-02 16:06:43 +07:00
nntrivi2001 e0b8d5641e chore: v2.8.60 — Claude panes scroll faster 2026-11-02 15:32:46 +07:00
nntrivi2001 faa96d1964 fix(terminal): Claude panes scroll 5 lines per wheel notch
Fullscreen Claude Code scrolls one line per wheel report when its terminal
does not answer XTVERSION as xterm.js (behind tmux it never does), and
xterm.js sends one report per notch — so a notch moved a single line.
spawnRun now sets CLAUDE_CODE_SCROLL_SPEED=5 unless the dashboard's env
already sets it; settings.json env and /config Scroll speed still win.
2026-11-02 15:32:46 +07:00
39 changed files with 2960 additions and 141 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
@@ -287,6 +287,11 @@ function UploadChip({ job }: { job: UploadJob }) {
{isArmed("stop") && !stopping && (
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
)}
{job.waiting && !stopping && (
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
{t("uploads.waiting")}
</p>
)}
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
{job.folder}
{job.name ? ` · ${job.name}` : ""}
+30 -4
View File
@@ -91,6 +91,7 @@ import {
type FsPlace,
type GitSource,
isHomePlace,
newUploadId,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
@@ -529,7 +530,13 @@ export function FileBrowser({
await fn();
if (done) toast.success(done);
} catch (e) {
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
toast.error(
errCode(e) === "EUPGRADE"
? t("fx.needsUpgrade")
: errCode(e) === "ESECRET"
? t("fx.secret")
: errText(e)
);
} finally {
setBusy(false);
changed();
@@ -682,13 +689,21 @@ export function FileBrowser({
// false once the server says a batch cannot be written here (an old helper).
let batchable = true;
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
const onWait = (waiting: boolean) => job.update({ waiting });
/** One file; false = the user cancelled. */
const one = async ({ file, rel }: { file: File; rel: string }) => {
if (signal.aborted) return false;
const dest = join(dir, rel);
show(rel);
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
const upload = newUploadId();
const send = (c: FsConflict) =>
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
api.files.uploadStaged(place, dest, file, c, {
upload,
onProgress: (n) => show(rel, n),
onWait,
signal,
});
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
@@ -701,6 +716,9 @@ export function FileBrowser({
} catch (e) {
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
} finally {
onWait(false);
void api.files.dropUpload(place, upload);
}
sent += file.size;
done++;
@@ -720,6 +738,7 @@ export function FileBrowser({
return a.choice;
};
if (signal.aborted) return "cancel";
const upload = newUploadId();
try {
const c = await api.files.uploadCheck(
place,
@@ -733,7 +752,12 @@ export function FileBrowser({
let policy = await choose(c.taken);
if (!policy) return "cancel";
const send = (p: FsConflict) =>
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
api.files.uploadBatchStaged(place, dir, b, p, {
upload,
onProgress: (n) => show(label, n),
onWait,
signal,
});
const r = await send(policy).catch(async (e) => {
// Taken meanwhile: ask about those, then once more.
if (errCode(e) !== "EEXISTS") throw e;
@@ -752,6 +776,9 @@ export function FileBrowser({
return "fallback";
}
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
} finally {
onWait(false);
void api.files.dropUpload(place, upload);
}
sent += size;
done += b.length;
@@ -1693,7 +1720,6 @@ export function FileBrowser({
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
</div>
)}
</div>
{ctx.menu && (
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
source ? { lane, source } : session ? { lane, session } : lane,
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
newUploadId: (() => {
let n = 0;
return () => `upload-id-${String(++n).padStart(8, "0")}`;
})(),
api: {
lanes: {
list: vi.fn(),
@@ -52,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
allTrash: vi.fn(),
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
uploadBatch: vi.fn(),
uploadStaged: vi.fn(),
uploadBatchStaged: vi.fn(),
dropUpload: vi.fn(async () => null),
uploadCheck: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
},
@@ -236,7 +241,7 @@ describe("FileExplorer", () => {
});
it("asks about a taken name on upload, and sends the choice", async () => {
vi.mocked(api.files.upload)
vi.mocked(api.files.uploadStaged)
.mockRejectedValueOnce(
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
)
@@ -249,20 +254,24 @@ describe("FileExplorer", () => {
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
await waitFor(() =>
expect(api.files.upload).toHaveBeenLastCalledWith(
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
1,
"a.txt",
file,
"keep",
expect.any(Function),
expect.any(AbortSignal)
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
)
);
// The retry names the SAME upload, so the staged bytes are not sent again,
// and the staged upload is dropped once answered.
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
expect(second).toBe(first);
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
});
it("sends many small files as one batch, asking once about every taken name", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
@@ -271,33 +280,32 @@ describe("FileExplorer", () => {
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Replace" }));
await waitFor(() =>
expect(api.files.uploadBatch).toHaveBeenCalledWith(
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function),
expect.any(AbortSignal)
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.upload).not.toHaveBeenCalled();
expect(api.files.uploadStaged).not.toHaveBeenCalled();
});
it("falls back to one request per file when the server cannot take a batch", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatch).not.toHaveBeenCalled();
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
open();
await screen.findByText("a.txt");
const mk = (rel: string) => {
@@ -307,8 +315,8 @@ describe("FileExplorer", () => {
};
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -320,7 +328,7 @@ describe("FileExplorer", () => {
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(api.files.upload).not.toHaveBeenCalled();
expect(api.files.uploadStaged).not.toHaveBeenCalled();
});
it("restores from the Trash", async () => {
@@ -86,6 +86,7 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { wheelReports, type WheelState } from "../../lib/wheelReports";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -524,9 +525,32 @@ export const TerminalView = memo(function TerminalView({
// wheel report itself — psmux scrolls, or hands it to the pane's app.
// ponytail: at 1;1, which is all psmux reports to the app anyway.
let ownWheel = false;
// Tracked: the reports are this pane's own too — one per
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
// asks for it; at the pointer's cell, with xterm's modifier bits.
const wheelState: WheelState = { partial: 0 };
const sendWheel = (ev: WheelEvent) => {
const screen = term.element?.querySelector(".xterm-screen");
const box = screen?.getBoundingClientRect();
if (!box || !box.height || !box.width) return;
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
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 button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
};
term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
// Shift+wheel stays xterm's (it reports nothing for it).
if (tracked && !ev.shiftKey) {
sendWheel(ev);
return false;
}
if (!tracked && ownWheel) {
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
@@ -21,6 +21,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { Terminal } from "@xterm/xterm";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
@@ -326,13 +327,33 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("lets the wheel through once the pane's program tracks the mouse", () => {
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
// report, so returning false here would kill scrolling entirely.
/** 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 screen = document.createElement("div");
screen.className = "xterm-screen";
screen.getBoundingClientRect = () =>
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
term.element.appendChild(screen);
};
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
// tmux with `mouse on` sets this. xterm would send ONE report per event and
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
termState.modes.mouseTrackingMode = "any";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
screenBox();
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.sent.length = 0;
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
// 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.
ws.sent.length = 0;
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
@@ -361,10 +382,11 @@ describe("TerminalView", () => {
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
// Tracked again (a tmux that does report): xterm sends it, never twice.
// Tracked again (a tmux that does report): sent once, by the tracked path.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
screenBox();
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(ws.sent).toHaveLength(3);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
@@ -19,15 +19,33 @@
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* table above it, so this lists them only for a member. **Commit signing**
* (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>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import {
Cloud,
Copy,
FileKey,
GitBranch,
KeyRound,
PenLine,
Plus,
RefreshCw,
Server,
Trash2,
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";
@@ -216,6 +234,339 @@ function GitHostForm({
);
}
/**
* 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,
keys,
keyLabel,
busy,
run,
}: {
id: GitIdentity;
keys: Key[];
keyLabel: (keyId: string | null | undefined) => string;
busy: boolean;
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 laneSigning = id.laneSigning ?? [];
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
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(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;
const ok = await run(
() =>
api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
}),
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (ok) setForm(null);
};
const readFile = async (file: File | undefined) => {
if (!file || !form) return;
// A key file is a few KB; anything far larger is not one.
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
const text = await file.text();
setForm({ ...form, text });
};
const usable = keys.filter((k) => k.id);
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
aria-label={t("gitIdentity.signTitle")}
>
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.signTitle")}
</h3>
{id.writable && !form && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "" })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
</button>
)}
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
{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"
>
<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>
{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>
{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 && (
<form
className="space-y-2 rounded border border-border p-2.5"
aria-label={t("gitIdentity.importTitle")}
onSubmit={(e) => {
e.preventDefault();
void doImport();
}}
>
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
<label className={`${BTN} cursor-pointer`}>
<FileKey className="h-3.5 w-3.5" />
{t("gitIdentity.importFile")}
<input
type="file"
className="sr-only"
onChange={(e) => void readFile(e.target.files?.[0])}
/>
</label>
<textarea
aria-label={t("gitIdentity.importPaste")}
placeholder={t("gitIdentity.importPaste")}
value={form.text}
spellCheck={false}
autoComplete="off"
rows={4}
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<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
type="submit"
disabled={busy || !form.text.trim()}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitIdentity.importSubmit")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -572,6 +923,10 @@ export function GitIdentitySettings() {
)}
</form>
{id.signing !== undefined && (
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
)}
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
@@ -616,6 +971,11 @@ export function GitIdentitySettings() {
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
{id.signing?.state === "on" && id.signing.key === k.id && (
<div className="text-[10px] text-status-success">
{t("gitIdentity.signingBadge")}
</div>
)}
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
@@ -7,7 +7,11 @@
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* 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.
* the key's name with that key, and a second GitHub key is tied to its host;
* 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";
@@ -31,6 +35,18 @@ vi.mock("../../../lib/api", () => ({
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
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: {},
})),
},
},
}));
@@ -38,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",
@@ -335,4 +352,130 @@ describe("GitIdentitySettings", () => {
});
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
it("imports a key without signing anything by itself", async () => {
const signingKey = {
...KEY,
id: "id_ed25519_signing",
type: "ed25519" as const,
name: "signing",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
...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(
within(form).getByLabelText(/paste the private key/),
"-----BEGIN OPENSSH PRIVATE KEY-----x"
);
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
});
// 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("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: "off", key: null, path: null }, laneSigning: [] })
);
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 />);
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(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);
});
});
+2 -2
View File
@@ -2,7 +2,7 @@
* @file UserAvatar.tsx
* @description An account's round picture: the signed-in account's own
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
@@ -23,7 +23,7 @@ export function UserAvatar({
}: {
name: string;
avatarAt?: string | null;
/** Another account's (staff only); omitted = the signed-in account's own. */
/** Another account's; omitted = the signed-in account's own. */
userId?: number;
/** How many letters of the name stand in for a missing picture. */
letters?: number;
+2
View File
@@ -262,6 +262,7 @@
"jobs.expand": "Show background tasks",
"uploads.stop": "Stop upload",
"uploads.stopConfirm": "Click again to stop",
"uploads.waiting": "The dashboard is restarting — the upload resumes when it is back.",
"uploads.stopping": "Stopping…",
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
@@ -645,6 +646,7 @@
"fx.pasted": "Pasted.",
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
"fx.secret": "Hidden folders at the top of a home (.ssh, .config…) are not editable here — use a terminal. For an SSH key to clone or sign commits: Settings → Git.",
"fx.conflictTitle": "Already there",
"fx.conflictBody": "This name is taken in the destination: {{names}}",
"fx.conflictAll": "Do this for every remaining file",
+38 -4
View File
@@ -883,6 +883,40 @@
"authorName": "Name",
"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": "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.",
"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)",
"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.",
"signingBadge": "Signs commits",
"authorSaved": "Commit author saved.",
"title": "Your git identity",
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
@@ -904,9 +938,9 @@
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
"gitHubSub": "ED25519 key — the git@… SSH address",
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
"gitHubStepAdd": "Add it to your account on the host and paste the whole line: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Personal settings → Register SSH key.",
"awsTitle": "AWS CodeCommit",
"awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.",
@@ -932,10 +966,10 @@
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
"tabGit": "GitHub · GitLab",
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
"tabOther": "Other hosts",
"addKey": "Add another key",
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
"namedGitSub": "ED25519 · for one more account on a git host.",
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
"namedAwsSub": "RSA · for one more AWS account.",
"removeKeyNamed": "Delete key {{name}}"
+2
View File
@@ -262,6 +262,7 @@
"jobs.expand": "Xem các việc chạy nền",
"uploads.stop": "Ngưng tải lên",
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
"uploads.waiting": "Dashboard đang khởi động lại — tải lên sẽ tiếp tục khi có kết nối.",
"uploads.stopping": "Đang ngưng…",
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
@@ -645,6 +646,7 @@
"fx.pasted": "Đã dán.",
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
"fx.secret": "Thư mục ẩn ở cấp đầu home (.ssh, .config…) không chỉnh được ở đây — hãy dùng terminal. Cần SSH key để clone hay ký commit: Settings → Git.",
"fx.conflictTitle": "Đã có sẵn",
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
+38 -4
View File
@@ -871,6 +871,40 @@
"authorName": "Tên",
"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": "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.",
"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)",
"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}}.",
"signingBadge": "Ký commit",
"authorSaved": "Đã lưu tác giả commit.",
"title": "Danh tính git của bạn",
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
@@ -892,9 +926,9 @@
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
"gitHubStepAdd": "Thêm vào tài khoản trên host, dán nguyên dòng: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Cài đặt cá nhân → Đăng ký SSH key.",
"awsTitle": "AWS CodeCommit",
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.",
@@ -920,10 +954,10 @@
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
"tabGit": "GitHub · GitLab",
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
"tabOther": "Host khác",
"addKey": "Thêm key khác",
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
"namedGitSub": "ED25519 · cho thêm một tài khoản trên git host.",
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
"removeKeyNamed": "Xoá key {{name}}"
@@ -0,0 +1,134 @@
/**
* @file uploadStaged.test.ts
* @description The resumable File Explorer upload (`api.files.uploadStaged`):
* a file goes in parts to `/fs/upload-part` and is committed with
* `?staged=<id>`; a dashboard that goes away mid-way (network error, a
* proxy's bare 502) is waited out and the upload resumes from the byte the
* server reports; a refusal with our own error code is not retried.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, newUploadId } from "../api";
type Answer = { status: number; body?: unknown } | "neterr";
/** A fake XHR that answers each send with the next scripted answer. */
class FakeXhr {
static answers: ((url: string, size: number) => Answer)[] = [];
static sent: { url: string; size: number }[] = [];
status = 0;
responseText = "";
upload: { onprogress: ((e: { loaded: number }) => void) | null } = { onprogress: null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
private url = "";
open(_m: string, url: string) {
this.url = url;
}
setRequestHeader() {}
abort() {
this.onabort?.();
}
send(body: Blob) {
FakeXhr.sent.push({ url: this.url, size: body.size });
const next = FakeXhr.answers.shift();
const a = next ? next(this.url, body.size) : { status: 500, body: { error: { code: "EX" } } };
queueMicrotask(() => {
if (a === "neterr") return this.onerror?.();
this.status = a.status;
this.responseText = a.body === undefined ? "" : JSON.stringify(a.body);
this.onload?.();
});
}
}
const MB = 1024 * 1024;
const q = (url: string) => new URL(url, "http://x").searchParams;
beforeEach(() => {
vi.useFakeTimers();
FakeXhr.answers = [];
FakeXhr.sent = [];
vi.stubGlobal("XMLHttpRequest", FakeXhr);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("api.files.uploadStaged", () => {
it("sends parts, waits out a restart, resumes from the server's count, then commits", async () => {
const file = new Blob([new Uint8Array(20 * MB)]);
const upload = newUploadId();
expect(upload).toMatch(/^[0-9a-f]{32}$/);
// part 1 ok; part 2 dies with the server; part 2 again after the restart;
// part 3; the commit's answer is lost once; then the stored answer.
FakeXhr.answers = [
() => ({ status: 200, body: { received: 8 * MB } }),
() => "neterr",
(url) => ({ status: 200, body: { received: Number(q(url).get("offset")) + 8 * MB } }),
() => ({ status: 200, body: { received: 20 * MB } }),
() => ({ status: 502 }),
() => ({ status: 201, body: { path: "big.bin" } }),
];
// While away: the status read says 10 MB arrived before the server died.
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ received: 10 * MB, total: 20 * MB, done: false }), {
headers: { "content-type": "application/json" },
})
);
vi.stubGlobal("fetch", fetchMock);
const waits: boolean[] = [];
const done = api.files.uploadStaged(7, "big.bin", file, "fail", {
upload,
onWait: (w) => waits.push(w),
});
await vi.runAllTimersAsync();
await expect(done).resolves.toEqual({ path: "big.bin" });
const parts = FakeXhr.sent.filter((s) => s.url.includes("/upload-part"));
expect(parts.map((p) => [q(p.url).get("offset"), p.size])).toEqual([
["0", 8 * MB],
[String(8 * MB), 8 * MB],
[String(10 * MB), 8 * MB],
[String(18 * MB), 2 * MB],
]);
for (const p of parts) expect(q(p.url).get("upload")).toBe(upload);
const commits = FakeXhr.sent.filter((s) => s.url.includes("/fs/upload?"));
expect(commits).toHaveLength(2);
expect(q(commits[0]!.url).get("staged")).toBe(upload);
expect(q(commits[0]!.url).get("path")).toBe("big.bin");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(waits).toEqual([true, false, true, false]);
});
it("goes on from the server's count on EOFFSET, and never retries our own refusal", async () => {
const file = new Blob([new Uint8Array(10)]);
FakeXhr.answers = [
() => ({ status: 409, body: { error: { code: "EOFFSET", message: "x", received: 10 } } }),
() => ({ status: 409, body: { error: { code: "EEXISTS", message: "taken", names: ["a"] } } }),
];
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
const settled = expect(done).rejects.toMatchObject({ code: "EEXISTS", names: ["a"] });
await vi.runAllTimersAsync();
await settled;
expect(FakeXhr.sent).toHaveLength(2);
});
it("gives up once the dashboard has been away too long", async () => {
const file = new Blob([new Uint8Array(4)]);
FakeXhr.answers = Array.from({ length: 200 }, () => () => "neterr" as const);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("Failed to fetch");
})
);
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
const settled = expect(done).rejects.toMatchObject({ code: "ENET" });
await vi.runAllTimersAsync();
await settled;
});
});
@@ -0,0 +1,42 @@
/**
* @file wheelReports.test.ts
* @description A touchpad scrolls 1:1 with the finger (one report per
* LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
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);
});
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
expect(sent).toBe(-1);
});
it("sends at least one report for a wheel notch, in either delta mode", () => {
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);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0);
expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0);
});
});
+234 -25
View File
@@ -1188,7 +1188,9 @@ const sendRaw = <T>(
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
let body: {
error?: { code?: string; message?: string; names?: string[]; received?: number };
} = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
@@ -1206,12 +1208,148 @@ const sendRaw = <T>(
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
received: body.error?.received,
})
);
};
xhr.send(payload);
});
/**
* A batch body (`/fs/upload-batch`): a 4-byte length, the JSON file list,
* then each file's bytes. The Blob is only composed, never read into memory.
*/
function batchBody(files: { file: Blob; rel: string }[]): Blob {
const head = new TextEncoder().encode(
JSON.stringify({
files: files.map((f) => ({
path: f.rel,
size: f.file.size,
mtime: f.file instanceof File ? f.file.lastModified : undefined,
})),
})
);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, head.length);
return new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" });
}
/** One part of a resumable upload (the server takes at most 16 MB). */
const UPLOAD_PART_BYTES = 8 * 1024 * 1024;
/** How long an upload waits for the dashboard to come back (a restart, an update). */
const UPLOAD_WAIT_MS = 3 * 60 * 1000;
/** A fresh id for one resumable upload (also outside a secure context). */
export function newUploadId(): string {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
}
type SendErr = Error & { code?: string; status?: number; received?: number };
/**
* The dashboard is away (restarting) rather than refusing: worth waiting for.
* A 5xx with none of our error codes is a proxy (nginx, Vite) answering for it.
*/
const isAway = (e: SendErr) =>
e.code === "ENET" || e.code === "EBUSY" || ((e.status ?? 0) >= 500 && !e.code);
const pause = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(Object.assign(new Error("aborted"), { code: "EABORT" }));
},
{ once: true }
);
});
/** Callbacks of a resumable upload. */
export interface StagedSendOpts {
/** The upload's id: pass the SAME one to retry a commit (another `conflict`) without resending. */
upload: string;
onProgress?: (sent: number) => void;
/** true while the dashboard is away and the upload waits to resume. */
onWait?: (waiting: boolean) => void;
signal?: AbortSignal;
}
/**
* Send `payload` in parts to `place`'s staging (`/fs/upload-part`), then
* POST `commitUrl(id)` to write it. A dropped connection or a restarting
* dashboard (network error, 502/503/504) is waited out for up to
* UPLOAD_WAIT_MS, and the upload resumes from the byte the server has.
*/
async function sendStaged<T>(
place: FsPlace,
commitUrl: (id: string) => string,
payload: Blob,
{ upload, onProgress, onWait, signal }: StagedSendOpts
): Promise<T> {
const total = payload.size;
let received = 0;
let awaySince = 0;
let delay = 500;
const waitOut = async (e: SendErr) => {
if (!isAway(e)) throw e;
if (!awaySince) awaySince = Date.now();
if (Date.now() - awaySince > UPLOAD_WAIT_MS) throw e;
onWait?.(true);
await pause(delay, signal);
delay = Math.min(delay * 2, 5000);
};
const back = () => {
if (awaySince) onWait?.(false);
awaySince = 0;
delay = 500;
};
const partUrl = (q: Record<string, string>) =>
fsUrl(place, "/upload-part", new URLSearchParams({ upload, ...q }));
// An empty file is still one (empty) part: it makes the upload known.
do {
try {
const r = await sendRaw<{ received: number }>(
partUrl({ offset: String(received), total: String(total) }),
payload.slice(received, received + UPLOAD_PART_BYTES),
(n) => onProgress?.(received + n),
signal
);
received = r.received;
back();
} catch (err) {
const e = err as SendErr;
if (e.code === "EOFFSET" && typeof e.received === "number") {
// The server has more (or less) than we thought: go on from there.
received = e.received;
back();
continue;
}
await waitOut(e);
try {
const s = await request<{ received: number; done: boolean }>(partUrl({}));
received = s.received;
} catch {
/* still away: the next part asks again */
}
}
onProgress?.(received);
} while (received < total);
for (;;) {
try {
const r = await sendRaw<T>(commitUrl(upload), new Blob([]), undefined, signal);
back();
return r;
} catch (err) {
// A commit whose answer was lost is answered again, never written twice.
await waitOut(err as SendErr);
}
}
}
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
export interface FsHome {
id: string;
@@ -1430,7 +1568,7 @@ export const api = {
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
@@ -3300,6 +3438,28 @@ export const api = {
onProgress,
signal
),
/**
* {@link files.upload}, resumable: sent in parts and written once whole,
* so a dashboard restart mid-way only pauses it ({@link sendStaged}).
*/
uploadStaged: (
t: FsPlace,
path: string,
file: Blob,
conflict: FsConflict,
opts: StagedSendOpts
) =>
sendStaged<{ path: string; skipped?: boolean }>(
t,
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
file,
opts
),
/** Forget a resumable upload's staged bytes (stopped, or given up). Never throws. */
dropUpload: (t: FsPlace, upload: string) =>
request<{ dropped: boolean }>(fsUrl(t, "/upload-part", new URLSearchParams({ upload })), {
method: "DELETE",
}).catch(() => null),
/**
* Many small files in ONE request, under the folder `dir` (each `rel`
* relative to it): a 4-byte length, the JSON file list, then the bytes.
@@ -3312,25 +3472,27 @@ export const api = {
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void,
signal?: AbortSignal
) => {
const head = new TextEncoder().encode(
JSON.stringify({
files: files.map((f) => ({
path: f.rel,
size: f.file.size,
mtime: f.file instanceof File ? f.file.lastModified : undefined,
})),
})
);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, head.length);
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
) =>
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
batchBody(files),
onProgress,
signal
);
},
),
/** {@link files.uploadBatch}, resumable like {@link files.uploadStaged}. */
uploadBatchStaged: (
t: FsPlace,
dir: string,
files: { file: Blob; rel: string }[],
conflict: FsConflict,
opts: StagedSendOpts
) =>
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
t,
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
batchBody(files),
opts
),
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
@@ -3356,6 +3518,32 @@ export const api = {
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
removeKeyById: (id: string) =>
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
/**
* POST /api/me/git/ssh-key/import — a private key the user already has
* (the file's text); `sign` signs commits with it at once, `replace`
* over a signing key they set themselves. Only its public half returns.
*/
importKey: (body: { privateKey: string; name?: string; sign?: boolean; replace?: boolean }) =>
request<GitIdentity & { imported: GitSshKey | null; signError: ApiErrorBody | null }>(
"/me/git/ssh-key/import",
{ method: "POST", body: JSON.stringify(body) }
),
/**
* 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(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) =>
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
@@ -5232,6 +5420,21 @@ export interface GitCredential {
hasToken: true;
}
/** One SSH key of `GET /api/me/git` — its public half only. */
export interface GitSshKey {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}
/** An API error's `{code, message}`. */
export interface ApiErrorBody {
code: string;
message: string;
}
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
export interface GitIdentity {
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
@@ -5249,13 +5452,19 @@ export interface GitIdentity {
* Every key the account holds: the default ED25519 and RSA first, then the
* named ones (`name`; `id` = its file name); absent from an older server.
*/
sshKeys?: {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}[];
sshKeys?: GitSshKey[];
/**
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
* or `foreign` — a signing key the user set themselves (`path`). Absent
* 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;
/**
+6 -1
View File
@@ -9,6 +9,8 @@
* kept in localStorage (refreshed every ~2 s), and the next page load shows
* a record that stopped being refreshed as an interrupted upload until it is
* dismissed. A fresh record belongs to another open tab and is left alone.
* A dashboard RESTART is not an interruption: the upload is sent in parts
* (`api.files.uploadStaged`) and the job shows `waiting` until it resumes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,6 +27,8 @@ export interface UploadJob {
sent: number;
bytes: number;
state: "running" | "stopping" | "interrupted";
/** The dashboard is away (a restart): the upload waits and then resumes. */
waiting?: boolean;
}
type Stored = UploadJob & { beat: number };
@@ -115,7 +119,8 @@ export function beginUpload(init: { folder: string; total: number; bytes: number
return {
id,
signal: ctrl.signal,
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent" | "waiting">>) =>
set(id, patch),
end: () => {
controllers.delete(id);
uploads = uploads.filter((u) => u.id !== id);
+59
View File
@@ -0,0 +1,59 @@
/**
* @file wheelReports.ts — turns a browser wheel event into the number of SGR
* wheel reports a terminal pane gets, in proportion to how far the finger or
* the wheel moved, the way a native terminal does.
*
* xterm.js sends ONE report per wheel event whatever its size, and damps a
* touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad
* 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.
* @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;
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
const DOM_DELTA_PIXEL = 0;
const DOM_DELTA_LINE = 1;
const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
}
/**
* Signed report count for `ev` — negative scrolls up. `rowPx` is one row's
* height on screen (CSS scale included), `rows` the terminal's height.
*/
export function wheelReports(
state: WheelState,
ev: Pick<WheelEvent, "deltaY" | "deltaMode">,
rowPx: number,
rows: number
): number {
if (!ev.deltaY || !(rowPx > 0)) return 0;
const sign = ev.deltaY < 0 ? -1 : 1;
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;
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;
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));
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.59 (rebuild client to match)
UI build v2.8.63 (rebuild client to match)
</span>
</p>
</div>
+38 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -886,7 +886,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making (or importing) a key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. **Commit signing** sits under the author: one picker signs every commit (terminal, Changes panel, agents) with one of their keys — `gpg.format ssh`, `user.signingkey` = that key's private file, `commit.gpgsign true` in their `~/.gitconfig`; a signing key they set up themselves is replaced only after a confirm — and **Import a key you have** takes a key file or paste (OpenSSH or PEM, ed25519 or RSA ≥ 2048, no passphrase: a dashboard commit has nowhere to type one) into the same `~/.config/agenthub/ssh/` as a named key (`signing`, `signing-2`…), written as them, and by default signs with it at once; only its public half is shown again. The SSH keys are shown one TAB per kind of host, each with only its own steps: **Git (GitHub, GitLab, Gitea, Backlog…)** (ED25519, for Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.59",
"version": "2.8.63",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.59",
"version": "2.8.63",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.59",
"version": "2.8.63",
"bin": {
"agenthub": "bin/agenthub.js"
},
+184 -1
View File
@@ -4,7 +4,9 @@
* place, across workspaces), delete into the trash and restore/purge, a
* streamed upload into new sub-folders with every conflict policy, a batch
* of many small files in one request (and, in a member's folder, as ONE
* write-as `extract` of a tar that real `tar` unpacks), a zip of
* write-as `extract` of a tar that real `tar` unpacks), a resumable upload
* sent in parts and committed from the staged bytes (and a member's upload
* written to a hidden temp file, moved onto its name only when whole), a zip of
* files and folders that unzips back to the same bytes — and the guards: no
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
* workspace folder itself never moved or deleted, cross-origin refused.
@@ -293,6 +295,144 @@ describe("File Explorer", () => {
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
});
it("resumes an upload sent in parts, and writes it only once every byte is staged", async () => {
const staging = require("../lib/upload-staging");
const id = "resume-test-0000000001";
const data = Buffer.from("0123456789abcdefghij");
const part = (offset, bytes, upload = id) =>
request(
"POST",
`/api/lanes/${lane1}/fs/upload-part?upload=${upload}&offset=${offset}&total=${data.length}`,
bytes,
{ "Content-Type": "application/octet-stream" }
);
const status = async () =>
(await request("GET", `/api/lanes/${lane1}/fs/upload-part?upload=${id}`)).body;
assert.deepEqual(await status(), { received: 0, total: null, done: false });
assert.equal((await part(0, data.subarray(0, 8))).body.received, 8);
// A part that does not start where the staged bytes end: 409 with the truth.
const off = await part(4, data.subarray(4, 12));
assert.equal(off.status, 409);
assert.equal(off.body.error.code, "EOFFSET");
assert.equal(off.body.error.received, 8);
// Committing early writes nothing.
const early = await request(
"POST",
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id}`
);
assert.equal(early.status, 409);
assert.equal(early.body.error.code, "EINCOMPLETE");
assert.ok(!fs.existsSync(path.join(W1, "res/file.bin")));
// A part past the end is refused.
assert.equal((await part(8, Buffer.alloc(13))).status, 413);
assert.equal((await status()).received, 8);
assert.equal((await part(8, data.subarray(8))).body.received, data.length);
const commit = () =>
request("POST", `/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id}`);
const done = await commit();
assert.equal(done.status, 201);
assert.deepEqual(done.body, { path: "res/file.bin" });
assert.deepEqual(fs.readFileSync(path.join(W1, "res/file.bin")), data);
// A commit whose answer was lost is answered again, never written twice.
fs.writeFileSync(path.join(W1, "res/file.bin"), "changed since");
const again = await commit();
assert.equal(again.status, 201);
assert.deepEqual(again.body, { path: "res/file.bin" });
assert.equal(fs.readFileSync(path.join(W1, "res/file.bin"), "utf8"), "changed since");
assert.deepEqual(await status(), { received: data.length, total: data.length, done: true });
// A taken name keeps the staged bytes for a retry with another policy.
const id2 = "resume-test-0000000002";
await part(0, data, id2);
const taken = await request(
"POST",
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id2}`
);
assert.equal(taken.status, 409);
assert.equal(taken.body.error.code, "EEXISTS");
const kept = await request(
"POST",
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id2}&conflict=keep`
);
assert.equal(kept.body.path, "res/file (1).bin");
assert.deepEqual(fs.readFileSync(path.join(W1, "res/file (1).bin")), data);
// A batch goes the same way.
const head = Buffer.from(JSON.stringify({ files: [{ path: "b1.txt", size: 2 }] }));
const len = Buffer.alloc(4);
len.writeUInt32BE(head.length);
const batch = Buffer.concat([len, head, Buffer.from("hi")]);
const id3 = "resume-test-0000000003";
await request(
"POST",
`/api/lanes/${lane1}/fs/upload-part?upload=${id3}&offset=0&total=${batch.length}`,
batch,
{ "Content-Type": "application/octet-stream" }
);
const b = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=res&staged=${id3}`);
assert.equal(b.status, 201);
assert.equal(fs.readFileSync(path.join(W1, "res/b1.txt"), "utf8"), "hi");
// Stop: forgotten. A bad id or an unknown upload is refused, never a path.
assert.equal(
(await request("DELETE", `/api/lanes/${lane1}/fs/upload-part?upload=${id}`)).status,
200
);
assert.equal((await status()).received, 0);
assert.equal(
(await request("GET", `/api/lanes/${lane1}/fs/upload-part?upload=../../etc`)).status,
400
);
assert.equal(
(await request("POST", `/api/lanes/${lane1}/fs/upload?path=x&staged=nope-nope-nope-nope`))
.status,
404
);
// Uploads are per user: another user's key never sees this one.
assert.equal(staging.status("42", id3).received, 0);
assert.ok(fs.statSync(path.join(staging.root(), "local")).isDirectory());
for (const i of [id2, id3]) staging.drop("local", i);
});
it("keeps the bytes of a part cut short, caps unfinished uploads, sweeps old ones", async () => {
const staging = require("../lib/upload-staging");
const { Readable } = require("node:stream");
const id = "cut-short-0000000001";
let sent = false;
const cut = new Readable({
read() {
if (!sent) {
sent = true;
this.push(Buffer.from("abcd"));
} else setTimeout(() => this.destroy(new Error("connection reset")), 30);
},
});
await assert.rejects(staging.putPart("u1", id, { offset: 0, total: 10, stream: cut }));
assert.equal(staging.status("u1", id).received, 4);
const rest = Readable.from([Buffer.from("efghij")]);
assert.equal(
(await staging.putPart("u1", id, { offset: 4, total: 10, stream: rest })).received,
10
);
// At most MAX_OPEN unfinished uploads per user.
for (let i = 1; i < staging.MAX_OPEN; i++) {
await staging.putPart("u1", `cap-test-${String(i).padStart(10, "0")}`, {
offset: 0,
total: 5,
stream: Readable.from([Buffer.from("a")]),
});
}
await assert.rejects(
staging.putPart("u1", "cap-test-overflow-01", {
offset: 0,
total: 5,
stream: Readable.from([Buffer.from("a")]),
}),
{ code: "ETOOMANY" }
);
// A day untouched: swept.
staging.sweep(Date.now() + staging.KEEP_MS + 60_000);
assert.equal(staging.status("u1", id).received, 0);
assert.deepEqual(fs.readdirSync(path.join(staging.root(), "u1")), []);
});
it("zips files and folders into one download that unzips to the same bytes", async () => {
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
assert.equal(r.status, 200);
@@ -404,6 +544,49 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
}
});
it("writes an upload to a hidden temp file and moves it onto the name only when complete", async () => {
const { Readable } = require("node:stream");
const prev = runAs.writeAsAsync;
const runs = [];
// write-as as a shell would run it: `cat >file` takes whatever arrived.
runAs.writeAsAsync = async (_a, file, args, { input } = {}) => {
runs.push([path.basename(file), ...args.map((a) => path.basename(a))]);
if (!args.length) {
const chunks = [];
for await (const c of input) chunks.push(c);
fs.writeFileSync(file, Buffer.concat(chunks));
} else if (args[0] === "move") fs.renameSync(file, args[1]);
else if (args[0] === "remove") fs.rmSync(file, { force: true });
};
try {
const out = await fx.upload(M, "up.txt", Readable.from([Buffer.from("whole")]), {
length: "5",
});
assert.equal(out.path, "up.txt");
assert.equal(fs.readFileSync(path.join(M, "up.txt"), "utf8"), "whole");
assert.match(runs[0][0], /^\.up\.txt\.agenthub-upload-/);
assert.deepEqual(runs[1].slice(1), ["move", "up.txt"]);
// The body ended early (a dropped connection, a dying server): nothing
// under the real name, and the temp file is removed.
runs.length = 0;
await assert.rejects(
fx.upload(M, "cut.txt", Readable.from([Buffer.from("par")]), { length: "10" }),
{ code: "EBADBODY" }
);
assert.ok(!fs.existsSync(path.join(M, "cut.txt")));
assert.deepEqual(
fs.readdirSync(M).filter((n) => n.includes("agenthub-upload")),
[]
);
assert.deepEqual(
runs.map((r) => r[1] ?? "write"),
["write", "remove"]
);
} finally {
runAs.writeAsAsync = prev;
}
});
it("says the helper needs updating instead of failing obscurely", async () => {
runAs.writeAsCanMove = () => false;
try {
+334
View File
@@ -0,0 +1,334 @@
/**
* @file Tests for importing an SSH key the user already has and signing
* commits with it (`POST /api/me/git/ssh-key/import`, `PUT /api/me/git/signing`,
* `server/lib/git-identity.js`): OpenSSH and PEM keys of both types read into
* 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, 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>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-signing-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.HOME = ROOT;
const { createApp } = require("../index");
let app;
let base;
const KEYS = path.join(ROOT, "keys");
function request(method, p, body) {
return fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
}
/** A key made by ssh-keygen itself: `{text, pub}` (pub = type + base64). */
function keygen(name, args, pass = "") {
const file = path.join(KEYS, name);
execFileSync("ssh-keygen", ["-q", ...args, "-N", pass, "-C", "me@x", "-f", file]);
const pub = fs.readFileSync(`${file}.pub`, "utf8").split(" ").slice(0, 2).join(" ");
return { text: fs.readFileSync(file, "utf8"), pub };
}
const pubOf = (line) => line.split(" ").slice(0, 2).join(" ");
const gitGet = (k) => {
try {
return execFileSync("git", ["config", "--global", "--get", k], { encoding: "utf8" }).trim();
} catch {
return "";
}
};
const removeAll = async () => {
await request("PUT", "/api/me/git/signing", { key: null });
await request("DELETE", "/api/me/git/ssh-key");
};
before(async () => {
fs.mkdirSync(KEYS);
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("POST /api/me/git/ssh-key/import", () => {
it("reads an OpenSSH ed25519 key into the public half ssh-keygen prints", async () => {
const k = keygen("ed", ["-t", "ed25519"]);
const r = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
assert.equal(r.status, 201);
assert.equal(pubOf(r.body.imported.publicKey), k.pub);
assert.equal(r.body.imported.name, "signing");
assert.equal(r.body.imported.id, "id_ed25519_signing");
// Nothing private in the answer, and the file is the owner's alone.
assert.ok(!JSON.stringify(r.body).includes("PRIVATE KEY"));
const file = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519_signing");
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
// What was written is a key ssh-keygen itself reads back.
assert.equal(
pubOf(execFileSync("ssh-keygen", ["-y", "-f", file], { encoding: "utf8" }).trim()),
k.pub
);
await removeAll();
});
it("reads OpenSSH RSA and PEM (PKCS#8) RSA keys", async () => {
const k = keygen("rsa", ["-t", "rsa", "-b", "2048"]);
const a = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
assert.equal(a.status, 201);
assert.equal(a.body.imported.id, "id_rsa_signing");
assert.equal(pubOf(a.body.imported.publicKey), k.pub);
const pem = crypto
.generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" });
const b = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: pem,
name: "work",
});
assert.equal(b.status, 201);
assert.equal(b.body.imported.id, "id_rsa_work");
await removeAll();
});
it("refuses a passphrase key, a public key, an ecdsa key and the same key twice", async () => {
const enc = keygen("enc", ["-t", "ed25519"], "secret-pass");
const r1 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.text });
assert.equal(r1.status, 400);
assert.equal(r1.body.error.code, "EKEYPASSPHRASE");
const r2 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.pub });
assert.equal(r2.body.error.code, "EBADKEY");
assert.match(r2.body.error.message, /PUBLIC key/);
const ec = keygen("ec", ["-t", "ecdsa"]);
const r3 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: ec.text });
assert.equal(r3.body.error.code, "EBADKEYTYPE");
const k = keygen("twice", ["-t", "ed25519"]);
assert.equal(
(await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text })).status,
201
);
const again = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "EEXISTS");
// A mangled paste never reaches the disk.
const lines = k.text.split("\n");
const i = 3; // inside the private section
lines[i] = lines[i].slice(0, 20) + (lines[i][20] === "Q" ? "R" : "Q") + lines[i].slice(21);
const bad = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: lines.join("\n"),
});
assert.equal(bad.status, 400);
await removeAll();
});
});
describe("PUT /api/me/git/signing", () => {
it("signs a real commit with the imported key in one step", async () => {
const k = keygen("sign", ["-t", "ed25519"]);
const r = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: k.text,
sign: true,
});
assert.equal(r.status, 201);
assert.equal(r.body.signError, null);
assert.deepEqual(r.body.signing, { state: "on", key: "id_ed25519_signing", path: null });
assert.equal(gitGet("gpg.format"), "ssh");
assert.equal(gitGet("commit.gpgsign"), "true");
// The PRIVATE file: ssh-keygen signs with it, no agent needed.
assert.match(gitGet("user.signingkey"), /agenthub-ssh\/id_ed25519_signing$/);
const repo = path.join(ROOT, "repo");
execFileSync("git", ["init", "-q", repo]);
const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "me@x" };
Object.assign(env, { GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "me@x" });
execFileSync("git", ["-C", repo, "commit", "-q", "--allow-empty", "-m", "signed"], { env });
const allowed = path.join(ROOT, "allowed");
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
const status = execFileSync(
"git",
["-C", repo, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
{ encoding: "utf8" }
).trim();
assert.equal(status, "G");
// Deleting the signing key stops signing, so commits keep working.
await request("DELETE", "/api/me/git/ssh-key?id=id_ed25519_signing");
assert.equal(gitGet("user.signingkey"), "");
assert.equal(gitGet("commit.gpgsign"), "");
await removeAll();
});
it("keeps a signing key the user set themselves unless told to replace it", async () => {
execFileSync("git", ["config", "--global", "user.signingkey", "~/.ssh/id_ed25519"]);
const k = keygen("mine", ["-t", "ed25519"]);
const r = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: k.text,
sign: true,
});
// The key is saved; only the switch was refused, and said so.
assert.equal(r.status, 201);
assert.equal(r.body.signError.code, "EFOREIGNSIGN");
assert.deepEqual(r.body.signing, { state: "foreign", key: null, path: "~/.ssh/id_ed25519" });
const off = await request("PUT", "/api/me/git/signing", { key: null });
assert.equal(off.status, 200);
assert.equal(gitGet("user.signingkey"), "~/.ssh/id_ed25519");
const on = await request("PUT", "/api/me/git/signing", {
key: r.body.imported.id,
replace: true,
});
assert.equal(on.status, 200);
assert.equal(on.body.signing.state, "on");
const stop = await request("PUT", "/api/me/git/signing", { key: null });
assert.equal(stop.body.signing.state, "off");
assert.equal(gitGet("gpg.format"), "");
const none = await request("PUT", "/api/me/git/signing", { key: "id_ed25519_nope" });
assert.equal(none.body.error.code, "ENOKEY");
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");
});
});
+37 -7
View File
@@ -6,6 +6,7 @@
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
* under each side's own rule — and in server mode a developer never reaches
* another user's home while an admin gets it read-only — except its Trash,
* and a resumable upload in parts lands in the home under the same rule,
* which the admin lists, restores from and purges as the member's own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -166,6 +167,38 @@ describe("File Explorer on the home", () => {
assert.ok(!fs.existsSync(path.join(WS, "id_ed25519")));
});
it("resumes an upload into the home in parts, under the home rule", async () => {
const raw = (method, p, body) =>
fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/octet-stream" },
body,
}).then(async (r) => ({ status: r.status, body: await r.json() }));
const id = "home-resume-00000001";
const data = Buffer.from("home bytes");
assert.equal(
(
await raw(
"POST",
`${H}/upload-part?upload=${id}&offset=0&total=${data.length}`,
data.subarray(0, 4)
)
).body.received,
4
);
assert.equal((await request("GET", `${H}/upload-part?upload=${id}`)).body.received, 4);
await raw(
"POST",
`${H}/upload-part?upload=${id}&offset=4&total=${data.length}`,
data.subarray(4)
);
// The home rule still holds for the staged bytes: no top-level dot entry.
assert.equal((await raw("POST", `${H}/upload?path=.ssh/x&staged=${id}`)).status, 403);
const ok = await raw("POST", `${H}/upload?path=docs/up.txt&staged=${id}`);
assert.equal(ok.status, 201);
assert.equal(fs.readFileSync(path.join(HOME, "docs/up.txt"), "utf8"), "home bytes");
});
it("refuses an unknown home", async () => {
assert.equal((await request("GET", "/api/homes/someone/fs/list")).status, 404);
});
@@ -256,13 +289,10 @@ describe("an admin manages a member's Trash", () => {
// …and every user's in one list, each item tagged with its home and owner.
const all = (await call("GET", "/api/homes/trash", dan)).body;
assert.deepEqual(
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
[
["junk", "cara", "cara"],
["old", "cara", "cara"],
]
);
assert.deepEqual(all.items.map((i) => [i.name, i.home, i.owner]).sort(), [
["junk", "cara", "cara"],
["old", "cara", "cara"],
]);
// A developer's merged view is only their own.
assert.deepEqual(
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
+3
View File
@@ -208,6 +208,9 @@ describe("lane-sources instructions and launch", () => {
// Claude Code hides its task tools from newer models without this, and the
// chat Progress panel is drawn from those calls.
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
// One wheel notch reaches claude as one report; it scrolls this many lines.
if (!process.env.CLAUDE_CODE_SCROLL_SPEED)
assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5"));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/);
+9 -4
View File
@@ -4,8 +4,9 @@
* server/lib/avatars.js): an account sets, reads and removes its own picture;
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
* another account never sees it through `/api/me`; staff read it at
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
* another account never sees it through `/api/me`; every account reads it at
* `/api/users/:id/avatar` (a developer too — only the picture, the rest of
* `/users` stays staff-only) and a workspace carries
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
* downloaded, and the avatar routes redirect to it; https only, never a
@@ -196,7 +197,7 @@ it("a picture from a link: https only, never a private or local address", async
}
});
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
it("every account reads another's avatar; workspaces carry owner_avatar_at", async () => {
const ava = await login("ava", "ava-pass-1");
const ann = await login("ann", "ann-pass-1");
const bob = await login("bob", "bob-pass-1");
@@ -210,8 +211,12 @@ it("staff read another account's avatar; workspaces carry owner_avatar_at", asyn
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
assert.equal(
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
403
200
);
// Only the picture: the rest of /users stays staff-only for a developer.
for (const path of [`/api/users/${id}`, `/api/users/${id}/details`, "/api/users"]) {
assert.equal((await fetch(BASE + path, { headers: { cookie: bob } })).status, 403, path);
}
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
.lanes;
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
+6
View File
@@ -128,6 +128,11 @@ const STAFF_ONLY_PREFIXES = [
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
// The one read under a staff-only prefix every signed-in user may make: an
// account's PICTURE (`GET /users/:id/avatar` — the image or a redirect to its
// link, nothing else of the account). System Health shows every user's name and
// `avatarAt` to everyone, so a developer refused here saw only initials.
const EVERYONE_READS = [/^\/users\/\d+\/avatar$/];
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
@@ -789,6 +794,7 @@ function authGuard(req, res, next) {
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "developer" &&
!(isRead && EVERYONE_READS.some((re) => re.test(lp))) &&
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
+43 -6
View File
@@ -634,16 +634,26 @@ async function purgeTrash(dir, userKey, id) {
// ── Upload ─────────────────────────────────────────────────────────────────
/** A pass-through that fails once more than `max` bytes went by. */
/** A pass-through that fails once more than `max` bytes went by (`seen()`: the count). */
function capped(max) {
let n = 0;
return new Transform({
const t = new Transform({
transform(chunk, _enc, cb) {
n += chunk.length;
if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
cb(null, chunk);
},
});
t.seen = () => n;
return t;
}
/** A body that stopped short of its announced length (a dropped connection). */
function shortBody(limit, length) {
if (length == null || length === "") return null;
const want = Number(length);
if (!Number.isSafeInteger(want) || limit.seen() === want) return null;
return bad("EBADBODY", `the upload ended early (${limit.seen()} of ${want} bytes)`);
}
/**
@@ -677,16 +687,41 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
}
const acct = accountAt(loc.abs);
const limit = capped(UPLOAD_MAX_BYTES);
if (acct) {
// write-as `replace`: a new file renamed over the name, as the member.
const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit });
await Promise.all([
const feed = (done) =>
Promise.all([
pipeline(stream, limit).catch((e) => {
limit.destroy(e);
throw e;
}),
done,
]);
if (acct && runAs.writeAsCanMove()) {
// As the member: a hidden temp file, moved onto the name only once every
// byte arrived. write-as cannot tell a dropped connection (or a server
// that died mid-upload) from the end of the file — writing the name
// itself would leave a truncated file there under its real name.
const tmp = path.join(
loc.parent.abs,
`.${loc.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
);
try {
await feed(runAs.writeAsAsync(acct, tmp, [], { input: limit }));
const short = shortBody(limit, length);
if (short) throw short;
// `move` never lands on an existing name: one taken meanwhile is refused.
await runAs.writeAsAsync(acct, tmp, ["move", loc.abs]).catch((e) => {
if (/exists/.test(e.message)) {
throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
}
throw e;
});
} catch (e) {
await runAs.writeAsAsync(acct, tmp, ["remove"]).catch(() => {});
throw e;
}
} else if (acct) {
// An older write-as without `move`: `replace` (a new file renamed over the name).
await feed(runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit }));
} else {
const tmp = path.join(
loc.parent.abs,
@@ -694,6 +729,8 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
);
try {
await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" }));
const short = shortBody(limit, length);
if (short) throw short;
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
await fs.promises.link(tmp, loc.abs).catch(async (e) => {
if (e.code === "EEXIST")
+436
View File
@@ -190,6 +190,11 @@ function makeKeyPair(comment) {
const pair = crypto.generateKeyPairSync("ed25519");
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
return encodeEd25519(seed, pub, comment);
}
/** An ed25519 key (32-byte seed + public half) in OpenSSH's own formats. */
function encodeEd25519(seed, pub, comment) {
const type = Buffer.from("ssh-ed25519");
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
const check = crypto.randomBytes(4);
@@ -220,6 +225,182 @@ function makeKeyPair(comment) {
};
}
/** `ssh-rsa …` for an RSA key object (private or public). */
function rsaPublicLine(key, comment) {
const jwk = crypto.createPublicKey(key).export({ format: "jwk" });
const blob = Buffer.concat([
sshString(Buffer.from("ssh-rsa")),
mpint(Buffer.from(jwk.e, "base64url")),
mpint(Buffer.from(jwk.n, "base64url")),
]);
return `ssh-rsa ${blob.toString("base64")} ${comment}\n`;
}
// ── An imported key ────────────────────────────────────────────────────────
/** A pasted key file is a few KB; anything far past that is not one. */
const IMPORT_MAX = 32 * 1024;
const PASSPHRASE_HINT =
"this key has a passphrase — a commit made from the dashboard has nowhere to type it. " +
'Make a copy without one on your own machine (ssh-keygen -p -N "" -f <copy>) and import the copy';
/** Reads SSH wire strings out of a buffer; EBADKEY on a short one. */
function wireReader(buf) {
let at = 0;
const need = (n) => {
if (at + n > buf.length) throw err("EBADKEY", "not a valid SSH private key");
};
return {
u32() {
need(4);
const v = buf.readUInt32BE(at);
at += 4;
return v;
},
bytes() {
const n = this.u32();
need(n);
const v = buf.subarray(at, at + n);
at += n;
return v;
},
};
}
const b64u = (b) => Buffer.from(b).toString("base64url");
const big = (b) => BigInt(`0x${Buffer.from(b).toString("hex") || "0"}`);
const unbig = (n) => {
let h = n.toString(16);
if (h.length % 2) h = `0${h}`;
return Buffer.from(h, "hex");
};
/** An SSH mpint's magnitude without its sign byte. */
const mag = (b) => (b[0] === 0 && b.length > 1 ? b.subarray(1) : b);
/**
* An `openssh-key-v1` file (`BEGIN OPENSSH PRIVATE KEY`) as a KeyObject plus
* its comment. Encrypted ones are refused (EKEYPASSPHRASE): reading them
* needs the passphrase, and signing from the dashboard would need it again.
*/
function parseOpenSsh(text) {
const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, "").replace(/\s+/g, "");
const buf = Buffer.from(b64, "base64");
const magic = Buffer.from("openssh-key-v1\0");
if (!buf.subarray(0, magic.length).equals(magic)) {
throw err("EBADKEY", "not a valid SSH private key");
}
const r = wireReader(buf.subarray(magic.length));
const cipher = r.bytes().toString();
r.bytes(); // kdf name
r.bytes(); // kdf options
if (cipher !== "none") throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
if (r.u32() !== 1) throw err("EBADKEY", "a key file holding one key");
r.bytes(); // the public blob — checked against the private half below
const p = wireReader(r.bytes());
if (p.u32() !== p.u32()) throw err("EBADKEY", "not a valid SSH private key");
const type = p.bytes().toString();
if (type === "ssh-ed25519") {
const pub = p.bytes();
const sk = p.bytes();
if (pub.length !== 32 || sk.length !== 64 || !sk.subarray(32).equals(pub)) {
throw err("EBADKEY", "not a valid SSH private key");
}
const key = crypto.createPrivateKey({
key: { kty: "OKP", crv: "Ed25519", d: b64u(sk.subarray(0, 32)), x: b64u(pub) },
format: "jwk",
});
return { key, comment: p.bytes().toString("utf8") };
}
if (type === "ssh-rsa") {
const n = mag(p.bytes());
const e = mag(p.bytes());
const d = mag(p.bytes());
const qi = mag(p.bytes());
const pp = mag(p.bytes());
const q = mag(p.bytes());
const D = big(d);
const key = crypto.createPrivateKey({
key: {
kty: "RSA",
n: b64u(n),
e: b64u(e),
d: b64u(d),
p: b64u(pp),
q: b64u(q),
dp: b64u(unbig(D % (big(pp) - 1n))),
dq: b64u(unbig(D % (big(q) - 1n))),
qi: b64u(qi),
},
format: "jwk",
});
return { key, comment: p.bytes().toString("utf8") };
}
throw err("EBADKEYTYPE", `an ${type.replace(/^ssh-/, "")} key — import an ed25519 or RSA one`);
}
/**
* A pasted or uploaded private key → `{type, privateKey, publicKey}` in the
* same formats a generated key is written in. Takes OpenSSH's own format and
* the PEM ones (PKCS#1, PKCS#8); refuses an encrypted key (EKEYPASSPHRASE),
* anything but ed25519 or RSA ≥ 2048 bits (EBADKEYTYPE), and a key whose
* halves do not sign and verify as one (EBADKEY).
*/
function parsePrivateKey(text, fallbackComment) {
const src = typeof text === "string" ? text.replace(/\r\n?/g, "\n").trim() : "";
if (!src || src.length > IMPORT_MAX || !/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(src)) {
throw err(
"EBADKEY",
/^ssh-|^ecdsa-/.test(src)
? "that is a PUBLIC key (.pub) — import the private key file next to it"
: "not an SSH private key — the file starts with -----BEGIN … PRIVATE KEY-----"
);
}
let key;
let comment = "";
if (/-----BEGIN OPENSSH PRIVATE KEY-----/.test(src)) {
({ key, comment } = parseOpenSsh(src));
} else {
if (/ENCRYPTED/.test(src)) throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
try {
key = crypto.createPrivateKey(src);
} catch (e) {
if (/passphrase/i.test(String(e?.code || e?.message))) {
throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
}
throw err("EBADKEY", "not a valid SSH private key");
}
}
const kind = key.asymmetricKeyType;
if (kind !== "ed25519" && kind !== "rsa") {
throw err("EBADKEYTYPE", `an ${kind || "unknown"} key — import an ed25519 or RSA one`);
}
if (kind === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
throw err("EBADKEYTYPE", "an RSA key under 2048 bits — too weak to sign with");
}
// The halves belong together: a mangled paste fails here, not at commit time.
const probe = Buffer.from("agenthub key check");
const hash = kind === "rsa" ? "sha256" : null;
if (!crypto.verify(hash, probe, crypto.createPublicKey(key), crypto.sign(hash, probe, key))) {
throw err("EBADKEY", "the key's private and public halves do not match");
}
const label =
String(comment || fallbackComment || "agenthub")
.replace(/[^\w.@+-]/g, "")
.slice(0, 64) || "agenthub";
if (kind === "ed25519") {
const jwk = key.export({ format: "jwk" });
return {
type: "ed25519",
...encodeEd25519(Buffer.from(jwk.d, "base64url"), Buffer.from(jwk.x, "base64url"), label),
};
}
return {
type: "rsa",
privateKey: key.export({ type: "pkcs1", format: "pem" }),
publicKey: rsaPublicLine(key, label),
};
}
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
function fingerprint(publicKey) {
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
@@ -321,6 +502,243 @@ async function generateSshKey(
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
}
/**
* Import a key the user already has (`privateKey`: the file's text) as a
* NAMED key beside the generated ones — `name`, else `signing`, `signing-2`…
* Written exactly like a generated key (0600, as the member), so it serves
* clones and pushes too; only its public half is ever returned. The same key
* twice is EEXISTS, naming the key that holds it.
*/
async function importSshKey(account, { privateKey, name = null, comment } = {}) {
const parsed = parsePrivateKey(privateKey, comment);
const keys = currentKeys(account);
const fp = fingerprint(parsed.publicKey);
const twin = keys.find((k) => fingerprint(k.publicKey) === fp);
if (twin) throw err("EEXISTS", `this key is already here as ${twin.name ?? twin.type}`);
let keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
if (keyName === null) {
keyName = "signing";
for (let n = 2; keys.some((k) => k.name === keyName); n++) keyName = `signing-${n}`;
}
if (!KEY_NAME_RE.test(keyName)) {
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
}
if (keys.some((k) => k.name === keyName))
throw err("EEXISTS", `a key named ${keyName} already exists`);
if (keys.length >= MAX_KEYS) throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
const id = keyIdFor(parsed.type, keyName);
const file = path.join(paths(account).sshDir, id);
writeText(account, file, parsed.privateKey, { secret: true });
writeText(account, `${file}.pub`, parsed.publicKey, { secret: false });
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
await setTerminalSsh(account, true).catch(() => {});
}
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
}
// ── Commit signing (`~/.gitconfig` gpg.format / user.signingkey) ───────────
/** One global git config value, or "" when unset. */
async function globalGet(account, key) {
const { git } = require("./worktree");
return git(homeOf(account), ["config", "--global", "--get", key]).then(
(r) => r.stdout.trim(),
() => ""
);
}
/**
* Whether the account's commits are signed with one of ITS AgentHub keys:
* `{state: "on", key}` (gpg.format ssh, user.signingkey = that key's private
* file — ssh-keygen signs with the private file, a `.pub` needs an agent the
* dashboard has not got — and commit.gpgsign true), `{state: "off"}`, or
* `{state: "foreign", path}` when the user set a signing key of their own
* (never touched without `replace`).
*/
async function signingState(account) {
const signingKey = await globalGet(account, "user.signingkey");
if (!signingKey) return { state: "off", key: null, path: null };
const k = currentKeys(account).find((x) => x.file === signingKey);
// Not one of our keys — the user's own, or one of ours deleted by hand
// (commits then fail): either way show the path, so a key can be picked.
if (!k) return { state: "foreign", key: null, path: signingKey };
const on =
(await globalGet(account, "gpg.format")) === "ssh" &&
(await globalGet(account, "commit.gpgsign")) === "true";
return { state: on ? "on" : "off", key: on ? k.id : null, path: null };
}
/**
* Sign every commit of the account with key `id` (null = stop signing).
* Over a signing key the user set themselves only with `replace`
* (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, clearForeign = false } = {}) {
const { git } = require("./worktree");
const home = homeOf(account);
const state = await signingState(account);
const unset = (key) =>
git(home, ["config", "--global", "--unset", key]).catch(() => {
/* already unset */
});
if (id == null) {
// 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");
await unset("gpg.format");
}
return signingState(account);
}
const k = currentKeys(account).find((x) => x.id === id);
if (!k) throw err("ENOKEY", "no such SSH key");
if (state.state === "foreign" && !replace) {
throw err(
"EFOREIGNSIGN",
`your ~/.gitconfig already signs with ${state.path} — confirm to switch to ${k.name ?? k.type}`
);
}
await git(home, ["config", "--global", "gpg.format", "ssh"]);
await git(home, ["config", "--global", "user.signingkey", k.file]);
await git(home, ["config", "--global", "commit.gpgsign", "true"]);
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
@@ -335,6 +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 —
// 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`);
@@ -1133,6 +1563,12 @@ module.exports = {
sshKeyInfo,
sshKeysInfo,
generateSshKey,
importSshKey,
parsePrivateKey,
signingState,
laneSigningList,
setLaneSigning,
setSigning,
removeSshKey,
terminalSshCommand,
terminalSshState,
+11
View File
@@ -49,6 +49,8 @@ const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
* Workspace groups them into tabs of up to four split panes each). */
const MAX_PANE_SLOT = 9;
// Lines a fullscreen `claude` scrolls per wheel report (see spawnRun's env).
const CLAUDE_SCROLL_SPEED = "5";
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
// it asks only for what its classifier reads as risky. `default` is kept for
@@ -663,6 +665,15 @@ function spawnRun(args) {
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
// wins, so `"0"` there turns them back off.
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
// Fullscreen claude scrolls by itself, and through tmux it cannot tell it
// runs in xterm.js (XTVERSION answers tmux), so it takes ONE line per wheel
// report — while xterm.js sends one report per notch (and damps a trackpad
// ×0.3). A notch moved a single line. CLAUDE_CODE_SCROLL_SPEED is Claude's
// own multiplier; the dashboard's env or the user's settings.json `env`
// (or /config → Scroll speed) still wins.
...(entry.launcher === "claude" && !process.env.CLAUDE_CODE_SCROLL_SPEED
? { CLAUDE_CODE_SCROLL_SPEED: CLAUDE_SCROLL_SPEED }
: {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
+252
View File
@@ -0,0 +1,252 @@
/**
* @file Resumable uploads for the File Explorer: an upload's bytes are sent in
* parts and kept on the HOST's disk under
* `<data dir>/upload-staging/<user key>/<id>/` (0700/0600; never in a member's
* home), so a restart of the dashboard — an update, a manual restart, a crash —
* only costs the part in flight: the browser asks how much arrived (`status`)
* and sends the rest. Only when every byte is here is the file written where
* it goes, by the SAME `upload`/`uploadBatch` code (and guards) as a one-shot
* upload, reading the staged file instead of the request (`commit`).
*
* `<id>` is the browser's own random name for one upload; ids live per user
* key, so one user can never read, extend or commit another's. A part must
* start exactly where the staged bytes end (`EOFFSET` with `received`), one
* request per id at a time (`EBUSY`), never past the `total` announced first.
* A committed upload keeps its answer (`result.json`) and drops its bytes, so
* a commit whose answer a restart swallowed is answered again, never written
* twice; a failed commit (a taken name, say) keeps the bytes for a retry with
* another `conflict`. At most `MAX_OPEN` unfinished uploads per user; anything
* untouched for `KEEP_MS` is swept.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { Transform } = require("node:stream");
const { pipeline } = require("node:stream/promises");
const { getDataDir } = require("./claude-home");
/** The largest upload (the browser sends a whole file — or a batch — under one id). */
const TOTAL_MAX = 512 * 1024 * 1024;
/** The largest part one request may carry. */
const PART_MAX = 16 * 1024 * 1024;
const MAX_OPEN = 8;
const KEEP_MS = 24 * 60 * 60 * 1000;
const ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
const KEY_RE = /^[A-Za-z0-9_-]{1,64}$/;
const bad = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
/** Ids (`<key>/<id>`) a request in THIS process is working on. */
const busy = new Set();
const root = () => path.join(getDataDir(), "upload-staging");
function dirOf(userKey, id) {
const key = String(userKey ?? "");
if (!KEY_RE.test(key)) throw bad("EBADBODY", "no user for this upload");
if (typeof id !== "string" || !ID_RE.test(id)) {
throw bad("EBADBODY", "an upload id is 16–64 letters, digits, '-' or '_'");
}
return { key, id, dir: path.join(root(), key, id), lock: `${key}/${id}` };
}
const dataFile = (d) => path.join(d.dir, "data");
const metaFile = (d) => path.join(d.dir, "meta.json");
const resultFile = (d) => path.join(d.dir, "result.json");
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
}
function writeJson(file, value) {
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
fs.renameSync(tmp, file);
}
const sizeOf = (d) => {
try {
return fs.statSync(dataFile(d)).size;
} catch {
return 0;
}
};
function int(v, name, max) {
const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
if (!Number.isSafeInteger(n) || n < 0 || n > max) throw bad("EBADBODY", `bad ${name}`);
return n;
}
/** Drop uploads untouched for KEEP_MS (every user's — the store is one). */
function sweep(now = Date.now()) {
let keys = [];
try {
keys = fs.readdirSync(root());
} catch {
return;
}
for (const key of keys) {
let ids = [];
try {
ids = fs.readdirSync(path.join(root(), key));
} catch {
continue;
}
for (const id of ids) {
if (busy.has(`${key}/${id}`)) continue;
const dir = path.join(root(), key, id);
try {
if (now - fs.statSync(dir).mtimeMs > KEEP_MS)
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* gone meanwhile */
}
}
}
}
/** Unfinished uploads of `key` (a committed one keeps only its answer). */
function openCount(key) {
let ids = [];
try {
ids = fs.readdirSync(path.join(root(), key));
} catch {
return 0;
}
return ids.filter((id) => !fs.existsSync(path.join(root(), key, id, "result.json"))).length;
}
/** Touch the folder so `sweep` counts from the last part, not the first. */
function touch(d) {
const now = new Date();
try {
fs.utimesSync(d.dir, now, now);
} catch {
/* best effort */
}
}
/** `{received, total, done}` of one upload (`received: 0`, `total: null` when unknown). */
function status(userKey, id) {
const d = dirOf(userKey, id);
const meta = readJson(metaFile(d));
if (!meta) return { received: 0, total: null, done: false };
const done = fs.existsSync(resultFile(d));
return { received: done ? meta.total : sizeOf(d), total: meta.total, done };
}
/**
* Append the request `stream` at `offset` of upload `id` (of `total` bytes in
* all). Returns `{received, total}`.
*/
async function putPart(userKey, id, { offset, total, stream }) {
const d = dirOf(userKey, id);
const at = int(offset, "offset", TOTAL_MAX);
const all = int(total, "total", TOTAL_MAX);
if (busy.has(d.lock)) throw bad("EBUSY", "this upload is already receiving a part");
busy.add(d.lock);
try {
let meta = readJson(metaFile(d));
if (!meta) {
sweep();
if (openCount(d.key) >= MAX_OPEN) {
throw bad("ETOOMANY", `at most ${MAX_OPEN} unfinished uploads at a time`);
}
fs.mkdirSync(d.dir, { recursive: true, mode: 0o700 });
meta = { total: all, createdAt: new Date().toISOString() };
writeJson(metaFile(d), meta);
}
if (meta.total !== all) throw bad("EBADBODY", `this upload is ${meta.total} bytes`);
if (fs.existsSync(resultFile(d))) return { received: all, total: all };
const have = sizeOf(d);
if (at !== have) {
throw bad("EOFFSET", `this upload has ${have} bytes, not ${at}`, { received: have });
}
const room = Math.min(PART_MAX, all - have);
let n = 0;
const cap = new Transform({
transform(chunk, _enc, cb) {
n += chunk.length;
if (n > room) return cb(bad("ETOOBIG", "this part goes past the upload's end"));
cb(null, chunk);
},
});
// A part cut short leaves what arrived: the next one starts from there.
await pipeline(stream, cap, fs.createWriteStream(dataFile(d), { flags: "a", mode: 0o600 }));
touch(d);
return { received: sizeOf(d), total: all };
} finally {
busy.delete(d.lock);
}
}
/**
* Write a fully staged upload: `write(stream, size)` is the one-shot upload's
* own code, given the staged bytes; it answers `{status, body}`. A commit
* already made answers its stored answer again; a failed one keeps the bytes.
*/
async function commit(userKey, id, write) {
const d = dirOf(userKey, id);
const meta = readJson(metaFile(d));
if (!meta) throw bad("ENOFILE", "no such upload — send it again");
if (busy.has(d.lock)) throw bad("EBUSY", "this upload is still being written");
busy.add(d.lock);
try {
const stored = readJson(resultFile(d));
if (stored) return stored;
const have = sizeOf(d);
if (have !== meta.total) {
throw bad("EINCOMPLETE", `this upload has ${have} of ${meta.total} bytes`, {
received: have,
});
}
const stream = fs.createReadStream(dataFile(d));
let out;
try {
out = await write(stream, have);
} finally {
stream.destroy();
}
writeJson(resultFile(d), out);
fs.rmSync(dataFile(d), { force: true });
touch(d);
return out;
} finally {
busy.delete(d.lock);
}
}
/**
* Forget one upload (the browser stopped it, or has its answer). Also while a
* part is still arriving: a Stop drops the request, and the part writes on
* into a file no longer listed — never refused, or the stopped upload would
* hold one of MAX_OPEN for a day.
*/
function drop(userKey, id) {
const d = dirOf(userKey, id);
fs.rmSync(d.dir, { recursive: true, force: true });
return { dropped: true };
}
module.exports = {
status,
putPart,
commit,
drop,
sweep,
root,
TOTAL_MAX,
PART_MAX,
MAX_OPEN,
KEEP_MS,
};
+2 -3
View File
@@ -21,7 +21,7 @@
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
* - GET /api/users/{id}/avatar (Auth) — one account's picture (every account)
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
* Exports `{ tags, schemas, paths }`, combined into the spec by
@@ -731,12 +731,11 @@ const paths = {
get: {
tags: ["Auth"],
operationId: "getUserAvatar",
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
summary: "One account's picture, for owner groups and System Health (every signed-in account)",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
302: { description: "Redirect to that account's avatar link" },
403: err("EADMIN — a developer"),
404: err("ENOAVATAR"),
},
},
+173 -4
View File
@@ -21,7 +21,7 @@ const tags = [
];
const errorCodes =
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.";
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG; a resumable upload adds 409 EOFFSET/EINCOMPLETE (with `error.received`)/EBUSY and 429 ETOOMANY.";
const schemas = {
FsEntry: {
@@ -141,12 +141,109 @@ const OPS = [
parameters: [
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "conflict", in: "query", schema: conflict },
{
name: "staged",
in: "query",
schema: { type: "string" },
description:
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
},
],
body: {
required: false,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
status: 201,
},
],
[
"get",
"/upload-part",
"How much of a resumable upload arrived",
{
opId: "UploadPartStatus",
note: "`{received, total, done}` for the caller's own upload id (`received: 0, total: null` when unknown). The browser asks this after the dashboard came back, then sends the rest.",
parameters: [
{
name: "upload",
in: "query",
required: true,
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
},
],
ok: {
description: "`{received, total, done}`",
content: {
"application/json": {
schema: {
type: "object",
properties: {
received: { type: "integer" },
total: { type: "integer", nullable: true },
done: { type: "boolean" },
},
},
},
},
},
},
],
[
"post",
"/upload-part",
"Send one part of a resumable upload (≤ 16 MB a part, 512 MB in all)",
{
opId: "UploadPart",
note: "Raw bytes appended at `offset` to the caller's upload `upload` of `total` bytes, staged on the host (`<data dir>/upload-staging/`) until `/upload` or `/upload-batch` names it with `?staged=`. A part must start where the staged bytes end (409 EOFFSET with `error.received`), one at a time per id (409 EBUSY); a part cut short keeps what arrived. At most 8 unfinished uploads per user (429 ETOOMANY); one untouched for a day is swept.",
parameters: [
{
name: "upload",
in: "query",
required: true,
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
},
{ name: "offset", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
{ name: "total", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
],
body: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
status: 201,
ok: {
description: "`{received, total}`",
content: {
"application/json": {
schema: {
type: "object",
properties: { received: { type: "integer" }, total: { type: "integer" } },
},
},
},
},
},
],
[
"delete",
"/upload-part",
"Forget a resumable upload's staged bytes",
{
opId: "UploadPartDrop",
parameters: [
{
name: "upload",
in: "query",
required: true,
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
},
],
ok: {
description: "`{dropped: true}`",
content: {
"application/json": {
schema: { type: "object", properties: { dropped: { type: "boolean" } } },
},
},
},
},
],
[
@@ -178,9 +275,16 @@ const OPS = [
parameters: [
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
{ name: "conflict", in: "query", schema: conflict },
{
name: "staged",
in: "query",
schema: { type: "string" },
description:
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
},
],
body: {
required: true,
required: false,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
status: 201,
@@ -233,7 +337,7 @@ function operations(prefix, idParam, opPrefix, note) {
tags: ["Files"],
summary,
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
operationId: `${opPrefix}${sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
operationId: `${opPrefix}${x.opId || sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
parameters: [idParam, ...(x.parameters || [])],
...(x.body ? { requestBody: x.body } : {}),
responses: { [x.status || 200]: x.ok || { description: "Done." } },
@@ -459,6 +563,71 @@ const paths = {
responses: { 200: { description: "The identity." } },
},
},
"/api/me/git/ssh-key/import": {
post: {
tags: ["Git identity"],
summary:
"Import a private key the user already has as a named key; with `sign`, sign commits with it at once",
operationId: "importSshKey",
requestBody: json(
{
privateKey: { type: "string", description: "The key file's text (OpenSSH or PEM)." },
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
sign: { type: "boolean" },
replace: { type: "boolean" },
},
["privateKey"]
),
responses: {
201: {
description:
"The identity, plus `imported` (the key's public half) and `signError` (a refused `sign`, else null).",
},
400: { description: "EBADKEY / EKEYPASSPHRASE / EBADKEYTYPE / EBADKEYNAME" },
409: { description: "EEXISTS (the same key, or the name is taken) / ETOOMANYKEYS" },
},
},
},
"/api/me/git/signing": {
put: {
tags: ["Git identity"],
summary:
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
operationId: "setCommitSigning",
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" },
409: { description: "EFOREIGNSIGN — ~/.gitconfig already signs with a key of its own." },
},
},
},
"/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"],
+99 -1
View File
@@ -9,7 +9,15 @@
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail
* without one; a new SSH key is labelled with that email, else
* `<username>@smartgift.vn`). The account is the signed-in user's
* `<username>@smartgift.vn`), a key the user already has imported as a
* named one (`POST /ssh-key/import` — the private text goes in, only its
* 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`, 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.
@@ -39,6 +47,12 @@ const STATUS = {
ETOOMANYKEYS: 409,
EBADALIAS: 400,
ENOKEY: 400,
EBADKEY: 400,
EKEYPASSPHRASE: 400,
EFOREIGNSIGN: 409,
ENOREPO: 409,
ENOTFOUND: 404,
EFOREIGN: 403,
};
function fail(res, e) {
@@ -61,6 +75,9 @@ async function view(account, username) {
// The regions a CodeCommit key ID saved for "every region" covers.
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),
@@ -106,6 +123,87 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
}
});
/**
* Import a private key the user already has: `{privateKey, name?, sign?,
* replace?}`. With `sign`, commits are signed with it at once (one step for
* the user); `replace` lets that take over a signing key they set themselves.
*/
router.post("/ssh-key/import", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const key = await identity.importSshKey(account, {
privateKey: req.body?.privateKey,
name: typeof req.body?.name === "string" ? req.body.name : null,
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
});
let signError = null;
if (req.body?.sign === true && key) {
// The key is saved either way; a refused switch is said, not thrown away.
await identity
.setSigning(account, { id: key.id, replace: req.body?.replace === true })
.catch((e) => {
signError = { code: e.code || "EINTERNAL", message: e.message };
});
}
res.status(201).json({ ...(await view(account, usernameOf(req))), imported: key, signError });
} catch (e) {
fail(res, e);
}
});
/** Sign commits with key `{key: id}`, or stop (`{key: null}`); `replace` as above. */
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,
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);
}
});
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
+63 -18
View File
@@ -8,6 +8,7 @@
* GET /api/homes/:who/fs/list|trash|zip|download
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
* GET|POST|DELETE /api/homes/:who/fs/upload-part a resumable upload's parts (then `?staged=`)
*
* `:who` is `me`, or — for an admin — another user's name: read-only
* (`403 EREADONLY` on every write) and written to the audit log. The one
@@ -24,6 +25,7 @@ const express = require("express");
const path = require("node:path");
const fx = require("../lib/file-explorer");
const staging = require("../lib/upload-staging");
const homes = require("../lib/homes");
const scope = require("../lib/scope");
const lanesLib = require("../lib/lanes");
@@ -46,12 +48,17 @@ function fail(res, err) {
EUPGRADE: 409,
EMEMBERGONE: 409,
ETOOBIG: 413,
EOFFSET: 409,
EBUSY: 409,
EINCOMPLETE: 409,
ETOOMANY: 429,
}[err.code] ?? 500;
res.status(status).json({
error: {
code: err.code || "EFS",
message: err.message,
...(Array.isArray(err.names) ? { names: err.names } : {}),
...(Number.isInteger(err.received) ? { received: err.received } : {}),
},
});
}
@@ -294,22 +301,57 @@ router.post(
TRASH_W
)
);
/**
* The body of an upload: the request, or with `?staged=<id>` the bytes sent
* before in parts (`upload-staging.js`; see the workspace routes).
*/
function stagedOr(req, write) {
if (req.query.staged === undefined) return write(req, req.headers["content-length"]);
req.resume();
return staging.commit(userKey(req), req.query.staged, write);
}
router.get(
"/:who/fs/upload-part",
handle(async (req, res) => res.json(staging.status(userKey(req), req.query.upload)), W)
);
router.post(
"/:who/fs/upload-part",
sameOriginGuard,
handle(async (req, res) => {
try {
res.json(
await staging.putPart(userKey(req), req.query.upload, {
offset: req.query.offset,
total: req.query.total,
stream: req,
})
);
} catch (err) {
req.resume();
throw err;
}
}, W)
);
router.delete(
"/:who/fs/upload-part",
sameOriginGuard,
handle(async (req, res) => res.json(staging.drop(userKey(req), req.query.upload)), W)
);
router.post(
"/:who/fs/upload",
sameOriginGuard,
handle(async (req, res, home) => {
try {
const out = await fx.upload(
home.dir,
typeof req.query.path === "string" ? req.query.path : "",
req,
{
conflict: req.query.conflict,
length: req.headers["content-length"],
trash: trashFor(req, home),
}
);
res.status(out.skipped ? 200 : 201).json(out);
const r = await stagedOr(req, async (stream, length) => {
const out = await fx.upload(
home.dir,
typeof req.query.path === "string" ? req.query.path : "",
stream,
{ conflict: req.query.conflict, length, trash: trashFor(req, home) }
);
return { status: out.skipped ? 200 : 201, body: out };
});
res.status(r.status).json(r.body);
} catch (err) {
req.resume();
throw err;
@@ -321,13 +363,16 @@ router.post(
sameOriginGuard,
handle(async (req, res, home) => {
try {
const out = await fx.uploadBatch(
home.dir,
typeof req.query.dir === "string" ? req.query.dir : "",
req,
{ conflict: req.query.conflict, trash: trashFor(req, home) }
);
res.status(201).json(out);
const r = await stagedOr(req, async (stream) => ({
status: 201,
body: await fx.uploadBatch(
home.dir,
typeof req.query.dir === "string" ? req.query.dir : "",
stream,
{ conflict: req.query.conflict, trash: trashFor(req, home) }
),
}));
res.status(r.status).json(r.body);
} catch (err) {
req.resume();
throw err;
+83 -18
View File
@@ -1879,6 +1879,7 @@ router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) =>
// folder's account. `conflict` (fail|skip|keep|replace) settles a taken
// name; 409 EEXISTS carries `names` so the browser can ask.
const fileExplorer = require("../lib/file-explorer");
const uploadStaging = require("../lib/upload-staging");
function fsFail(res, err) {
const status =
@@ -1894,12 +1895,17 @@ function fsFail(res, err) {
EMEMBERGONE: 409,
ETOOBIG: 413,
ENOHOME: 404,
EOFFSET: 409,
EBUSY: 409,
EINCOMPLETE: 409,
ETOOMANY: 429,
}[err.code] ?? 500;
res.status(status).json({
error: {
code: err.code || "EFS",
message: err.message,
...(Array.isArray(err.names) ? { names: err.names } : {}),
...(Number.isInteger(err.received) ? { received: err.received } : {}),
},
});
}
@@ -2056,6 +2062,61 @@ router.post("/:id/fs/trash/purge", sameOriginGuard, async (req, res) => {
}
});
/**
* An upload's body: the request itself, or — with `?staged=<id>` — the bytes
* this user already sent in parts (`upload-staging.js`), so a restart in the
* middle costs only the part in flight. `write(stream, length)` answers
* `{status, body}`; a staged commit made before answers its stored answer.
*/
function stagedOr(req, write) {
const id = req.query.staged;
if (id === undefined) {
return write(req, req.headers["content-length"]);
}
req.resume();
return uploadStaging.commit(fsUserKey(req), id, write);
}
/**
* One part of a resumable upload (`?upload=&offset=&total=`, raw bytes); GET = how
* much arrived (`{received, total, done}`), DELETE = forget it. The bytes stay
* on the host until `/fs/upload` or `/fs/upload-batch` names `?staged=<id>`.
*/
router.get("/:id/fs/upload-part", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json(uploadStaging.status(fsUserKey(req), req.query.upload));
} catch (err) {
fsFail(res, err);
}
});
router.post("/:id/fs/upload-part", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json(
await uploadStaging.putPart(fsUserKey(req), req.query.upload, {
offset: req.query.offset,
total: req.query.total,
stream: req,
})
);
} catch (err) {
req.resume();
fsFail(res, err);
}
});
router.delete("/:id/fs/upload-part", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json(uploadStaging.drop(fsUserKey(req), req.query.upload));
} catch (err) {
fsFail(res, err);
}
});
/**
* Upload one file, streamed (no body parser buffers it), to `?path=` — any
* missing sub-folders are made (a folder upload sends each file with its
@@ -2065,17 +2126,17 @@ router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const out = await fileExplorer.upload(
t.cwd,
typeof req.query.path === "string" ? req.query.path : "",
req,
{
conflict: req.query.conflict,
length: req.headers["content-length"],
trash: fsTrashFor(req, t),
}
);
res.status(out.skipped ? 200 : 201).json(out);
const write = async (stream, length) => {
const out = await fileExplorer.upload(
t.cwd,
typeof req.query.path === "string" ? req.query.path : "",
stream,
{ conflict: req.query.conflict, length, trash: fsTrashFor(req, t) }
);
return { status: out.skipped ? 200 : 201, body: out };
};
const r = await stagedOr(req, write);
res.status(r.status).json(r.body);
} catch (err) {
req.resume();
fsFail(res, err);
@@ -2091,13 +2152,17 @@ router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const out = await fileExplorer.uploadBatch(
t.cwd,
typeof req.query.dir === "string" ? req.query.dir : "",
req,
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
);
res.status(201).json(out);
const write = async (stream) => ({
status: 201,
body: await fileExplorer.uploadBatch(
t.cwd,
typeof req.query.dir === "string" ? req.query.dir : "",
stream,
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
),
});
const r = await stagedOr(req, write);
res.status(r.status).json(r.body);
} catch (err) {
req.resume();
fsFail(res, err);
+2 -1
View File
@@ -23,7 +23,8 @@
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
* owner groups and System Health — the one `/users` read a developer may make
* too (`EVERYONE_READS` in server/lib/auth.js).
*
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
* user form: display name, email, role, disabled, a new password, workspaces